From 496f4c986671107fb18b1049c46b728da36e0ae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:14:23 +0000 Subject: [PATCH 01/11] Upgrade to Rutie 0.10.2 and make the extension memory safe Rutie 0.10 makes `Object::send` unsafe, so the bindings are rewritten around what the new version offers, and so that no input from Ruby can crash the interpreter. Before this, the test suite itself segfaulted Ruby 2.7 in `FasterPath.join`, and invalid UTF-8, `nil` or a missing argument aborted the process from a Rust panic. Rust extension: - `join` used `rb_scan_args` through a `transmute`d `argv`; it now reads the arguments as a slice and raises `ArgumentError` when given none. - Every method catches panics and raises them as `RuntimeError`, raises Ruby exceptions only after Rust values are dropped, and calls Ruby code (`to_path`, `to_s`, `respond_to?`) only through `protect_send`. - Paths are handled as bytes in the string's own encoding instead of `&str`, so non-UTF-8 paths work; results keep the input's encoding. Strings that aren't ASCII-compatible raise `Encoding::CompatibilityError` and null bytes raise `ArgumentError`, as in Ruby. - Path arguments may respond to `to_path`, as with Ruby's `File` methods. Strings returned by Ruby code are copied before more Ruby code runs, so the GC can't free them while in use. - Directory errors raise the right `Errno::*` (the code named a nonexistent `Errno::NOENT`); unreadable entries no longer panic. - `Pathname`s are built by setting `@path`, like `Pathname#initialize`, so no Ruby code runs while reading a directory. - Wrong argument counts raise `ArgumentError`; `add_trailing_separator` raises `TypeError` for a non-path instead of aborting. - Remove the unsafe word-at-a-time `memrnchr` (its fast path could never be taken) and `lazy_static`; build a `cdylib`; use edition 2021. Behaviour fixes found on the way, now matching Ruby: - `extname` ignores leading dots ("..foo", "a/..b") and follows the Ruby it runs in for a trailing dot (Ruby 2.7 returns "."). - `cleanpath_conservative("a/b/")` returned "b/" instead of "a/b/". - `del_trailing_separator("")` returned "/" instead of "". - `basename` only removes an extension at a character boundary (cp932). - `relative_path_from` errors use the same messages as `Pathname`. Tests: - Many test classes under test/refinements and test/ripple_effects had the same names as the main ones, so their methods replaced each other and several tests never ran; they now have unique names. - Add test/safety_test.rb for the inputs that used to crash Ruby. - The `ENCODING=true` tests now pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- Cargo.lock | 29 +- Cargo.toml | 8 +- lib/faster_path.rb | 2 +- src/basename.rs | 113 ++-- src/chop_basename.rs | 45 +- src/cleanpath_aggressive.rs | 97 +-- src/cleanpath_conservative.rs | 155 +++-- src/debug.rs | 29 - src/dirname.rs | 56 +- src/extname.rs | 106 +++- src/helpers.rs | 45 +- src/lib.rs | 151 ++--- src/memrnchr.rs | 70 --- src/path_parsing.rs | 26 +- src/pathname.rs | 552 ++++++++++-------- src/pathname_sys.rs | 24 - src/plus.rs | 102 ++-- src/prepend_prefix.rs | 19 +- src/relative_path_from.rs | 109 ++-- src/ruby.rs | 444 ++++++++++++++ test/cleanpath_conservative_test.rb | 6 + test/extname_test.rb | 12 +- test/refinements/absoulte_test.rb | 2 +- .../add_trailing_separator_test.rb | 2 +- test/refinements/basename_test.rb | 2 +- test/refinements/chop_basename_test.rb | 2 +- test/refinements/directory_test.rb | 2 +- test/refinements/dirname_test.rb | 2 +- test/refinements/extname_test.rb | 6 +- .../has_trailing_separator_test.rb | 2 +- test/refinements/plus_test.rb | 2 +- test/refinements/relative_test.rb | 2 +- .../cleanpath_aggressive_test.rb | 2 +- .../cleanpath_conservative_test.rb | 2 +- .../del_trailing_separator_test.rb | 2 +- .../has_trailing_separator_test.rb | 2 +- test/ripple_effects/join_test.rb | 2 +- test/ripple_effects/plus_test.rb | 2 +- test/safety_test.rb | 140 +++++ test/test_helper.rb | 2 + 40 files changed, 1484 insertions(+), 894 deletions(-) delete mode 100644 src/debug.rs delete mode 100644 src/memrnchr.rs delete mode 100644 src/pathname_sys.rs create mode 100644 src/ruby.rs create mode 100644 test/safety_test.rb diff --git a/Cargo.lock b/Cargo.lock index 935aca7..4a6d6c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,40 +1,39 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 4 + [[package]] name = "faster_path" version = "0.0.1" dependencies = [ - "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", - "memchr 2.2.0 (registry+https://github.com/rust-lang/crates.io-index)", - "rutie 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", + "memchr", + "rutie", ] [[package]] name = "lazy_static" -version = "1.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.58" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "memchr" -version = "2.2.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "rutie" -version = "0.6.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97641d6bd3731d263d914141e960a8b515bf5ff4ca6c961b25ca4b2694c6490" dependencies = [ - "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", - "libc 0.2.58 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static", + "libc", ] - -[metadata] -"checksum lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bc5729f27f159ddd61f4df6228e827e86643d4d3e7c32183cb30a1c08f604a14" -"checksum libc 0.2.58 (registry+https://github.com/rust-lang/crates.io-index)" = "6281b86796ba5e4366000be6e9e18bf35580adf9e63fbe2294aadb587613a319" -"checksum memchr 2.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2efc7bc57c883d4a4d6e3246905283d8dae951bb3bd32f49d6ef297f546e1c39" -"checksum rutie 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "695eb534c22e60d5723d57be6b3b6a4e9367e8b203e607a19eb94d03cb32ea34" diff --git a/Cargo.toml b/Cargo.toml index 6c6cfa9..7a4950e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,15 +7,15 @@ homepage = "https://github.com/danielpclark/faster_path" repository = "https://github.com/danielpclark/faster_path" license = "MIT OR Apache-2.0" readme = "README.md" +edition = "2021" [package.metadata.thermite] github_releases = true [lib] name = "faster_path" -crate-type = ["dylib"] +crate-type = ["cdylib"] [dependencies] -rutie = "0.6.0" -lazy_static = "1.3" -memchr = "2.2" +rutie = "0.10.2" +memchr = "2" diff --git a/lib/faster_path.rb b/lib/faster_path.rb index 3c0f40c..b50ff00 100644 --- a/lib/faster_path.rb +++ b/lib/faster_path.rb @@ -22,7 +22,7 @@ module FasterPath end Fiddle::Function. - new(Fiddle.dlopen(FFI_LIBRARY)['Init_faster_pathname'], [], Fiddle::TYPE_VOIDP). + new(Fiddle.dlopen(FFI_LIBRARY)['Init_faster_pathname'], [], Fiddle::TYPE_VOID). call Public.class_eval do diff --git a/src/basename.rs b/src/basename.rs index a663e52..f8ddfc7 100644 --- a/src/basename.rs +++ b/src/basename.rs @@ -1,146 +1,157 @@ -extern crate memchr; +use memchr::memrchr; -use self::memchr::memrchr; +use crate::path_parsing::{find_last_sep_pos, find_last_non_sep_pos, SEP_BYTES}; -use path_parsing::{find_last_sep_pos, find_last_non_sep_pos}; +pub fn basename<'a>(path: &'a [u8], ext: &[u8]) -> &'a [u8] { + let base = last_component(path); + &base[..ext_end(base, ext)] +} -pub fn basename<'a>(path: &'a str, ext: &str) -> &'a str { - let bytes: &[u8] = path.as_bytes(); +// The last component of the path, without trailing separators; "/" when +// there are only separators. +pub fn last_component(path: &[u8]) -> &[u8] { let mut left: usize = 0; - let mut right: usize = bytes.len(); - if let Some(last_slash_pos) = find_last_sep_pos(bytes) { + let mut right: usize = path.len(); + if let Some(last_slash_pos) = find_last_sep_pos(path) { if last_slash_pos == right - 1 { - if let Some(pos) = find_last_non_sep_pos(&bytes[..last_slash_pos]) { + if let Some(pos) = find_last_non_sep_pos(&path[..last_slash_pos]) { right = pos + 1; } else { - return "/"; + return SEP_BYTES; } - if let Some(pos) = find_last_sep_pos(&bytes[..right]) { + if let Some(pos) = find_last_sep_pos(&path[..right]) { left = pos + 1; } } else { left = last_slash_pos + 1; } } - &path[left..left + ext_end(&bytes[left..right], ext)] + &path[left..right] } -fn ext_end(slice: &[u8], ext: &str) -> usize { +// Where `slice` ends without the extension `ext`. `ext` is either an +// extension to remove, or a byte followed by `*` to remove everything from +// the last occurrence of that byte, such as ".*". +pub fn ext_end(slice: &[u8], ext: &[u8]) -> usize { if ext.len() >= slice.len() || slice == b"." || slice == b".." { return slice.len(); } - let ext_bytes = ext.as_bytes(); - if ext_bytes.len() == 2 && *ext_bytes.get(1).unwrap() == b'*' { - match memrchr(*ext_bytes.get(0).unwrap(), slice) { + if let [first, b'*'] = *ext { + match memrchr(first, slice) { Some(end) if end != 0 => return end, _ => {} }; - } else if slice.ends_with(ext_bytes) { - return slice.len() - ext_bytes.len(); + } else if slice.ends_with(ext) { + return slice.len() - ext.len(); } slice.len() } +#[cfg(test)] +fn basename_str<'a>(path: &'a str, ext: &str) -> &'a str { + std::str::from_utf8(basename(path.as_bytes(), ext.as_bytes())).unwrap() +} + #[test] fn non_dot_asterisk_ext() { // This is undocumented Ruby functionality. We match it in case some code out there relies on it. - assert_eq!(basename("abc", "b*"), "a"); - assert_eq!(basename("abc", "abc"), "abc"); - assert_eq!(basename("abc", "a*"), "abc"); - assert_eq!(basename("playlist", "l*"), "play"); + assert_eq!(basename_str("abc", "b*"), "a"); + assert_eq!(basename_str("abc", "abc"), "abc"); + assert_eq!(basename_str("abc", "a*"), "abc"); + assert_eq!(basename_str("playlist", "l*"), "play"); // Treated as literal "*": - assert_eq!(basename("playlist", "yl*"), "playlist"); - assert_eq!(basename("playl*", "yl*"), "pla"); + assert_eq!(basename_str("playlist", "yl*"), "playlist"); + assert_eq!(basename_str("playl*", "yl*"), "pla"); } #[test] fn empty() { - assert_eq!(basename("", ""), ""); - assert_eq!(basename("", ".*"), ""); - assert_eq!(basename("", ".a"), ""); + assert_eq!(basename_str("", ""), ""); + assert_eq!(basename_str("", ".*"), ""); + assert_eq!(basename_str("", ".a"), ""); } #[test] fn sep() { - assert_eq!(basename("/", ""), "/"); - assert_eq!(basename("//", ""), "/"); + assert_eq!(basename_str("/", ""), "/"); + assert_eq!(basename_str("//", ""), "/"); } #[test] fn trailing_dot() { - assert_eq!(basename("file.test.", ""), "file.test."); - assert_eq!(basename("file.test.", "."), "file.test"); - assert_eq!(basename("file.test.", ".*"), "file.test"); + assert_eq!(basename_str("file.test.", ""), "file.test."); + assert_eq!(basename_str("file.test.", "."), "file.test"); + assert_eq!(basename_str("file.test.", ".*"), "file.test"); } #[test] fn trailing_dot_dot() { - assert_eq!(basename("a..", ".."), "a"); - assert_eq!(basename("a..", ".*"), "a."); + assert_eq!(basename_str("a..", ".."), "a"); + assert_eq!(basename_str("a..", ".*"), "a."); } #[test] fn dot() { - assert_eq!(basename(".", ""), "."); - assert_eq!(basename(".", "."), "."); - assert_eq!(basename(".", ".*"), "."); + assert_eq!(basename_str(".", ""), "."); + assert_eq!(basename_str(".", "."), "."); + assert_eq!(basename_str(".", ".*"), "."); } #[test] fn dot_dot() { - assert_eq!(basename("..", ""), ".."); - assert_eq!(basename("..", ".*"), ".."); - assert_eq!(basename("..", ".."), ".."); - assert_eq!(basename("..", "..."), ".."); + assert_eq!(basename_str("..", ""), ".."); + assert_eq!(basename_str("..", ".*"), ".."); + assert_eq!(basename_str("..", ".."), ".."); + assert_eq!(basename_str("..", "..."), ".."); } #[test] fn non_dot_ext() { - assert_eq!(basename("abc", "bc"), "a"); + assert_eq!(basename_str("abc", "bc"), "a"); } #[test] fn basename_eq_ext() { - assert_eq!(basename(".x", ".x"), ".x"); - assert_eq!(basename(".x", ".*"), ".x"); + assert_eq!(basename_str(".x", ".x"), ".x"); + assert_eq!(basename_str(".x", ".*"), ".x"); } #[test] fn absolute() { - assert_eq!(basename("/a/b///c", ""), "c"); + assert_eq!(basename_str("/a/b///c", ""), "c"); } #[test] fn trailing_slashes_absolute() { - assert_eq!(basename("/a/b///c//////", ""), "c"); + assert_eq!(basename_str("/a/b///c//////", ""), "c"); } #[test] fn relative() { - assert_eq!(basename("b///c", ""), "c"); + assert_eq!(basename_str("b///c", ""), "c"); } #[test] fn trailing_slashes_relative() { - assert_eq!(basename("b/c//", ""), "c"); + assert_eq!(basename_str("b/c//", ""), "c"); } #[test] fn root() { - assert_eq!(basename("//c", ""), "c"); + assert_eq!(basename_str("//c", ""), "c"); } #[test] fn trailing_slashes_root() { - assert_eq!(basename("//c//", ""), "c"); + assert_eq!(basename_str("//c//", ""), "c"); } #[test] fn trailing_slashes_relative_root() { - assert_eq!(basename("c//", ""), "c"); + assert_eq!(basename_str("c//", ""), "c"); } #[test] fn edge_case_all_seps() { - assert_eq!("/", basename("///", ".*")); + assert_eq!("/", basename_str("///", ".*")); } diff --git a/src/chop_basename.rs b/src/chop_basename.rs index fa379ec..eb5a0da 100644 --- a/src/chop_basename.rs +++ b/src/chop_basename.rs @@ -1,30 +1,39 @@ -use path_parsing::{find_last_non_sep_pos, find_last_sep_pos}; -use std::str; +use crate::path_parsing::{find_last_non_sep_pos, find_last_sep_pos}; -pub fn chop_basename<'a>(input: &'a str) -> Option<(&'a str, &'a str)> { - let bytes = input.as_bytes(); - let len = find_last_non_sep_pos(&bytes)? + 1; - let base_start = find_last_sep_pos(&bytes[..len]).map_or(0, |pos| pos + 1); +pub fn chop_basename(input: &[u8]) -> Option<(&[u8], &[u8])> { + let len = find_last_non_sep_pos(input)? + 1; + let base_start = find_last_sep_pos(&input[..len]).map_or(0, |pos| pos + 1); if base_start == len { return None; } Some((&input[0..base_start], &input[base_start..len])) } +#[cfg(test)] +fn chop_basename_str(input: &str) -> Option<(&str, &str)> { + chop_basename(input.as_bytes()).map(|(dirname, basename)| { + (std::str::from_utf8(dirname).unwrap(), std::str::from_utf8(basename).unwrap()) + }) +} + #[test] fn it_chops_the_basename_and_dirname() { - assert_eq!(chop_basename(""), None ); - assert_eq!(chop_basename("/"), None ); - assert_eq!(chop_basename("."), Some(("", ".")) ); - assert_eq!(chop_basename("asdf/asdf"), Some(("asdf/", "asdf")) ); - assert_eq!(chop_basename("asdf.txt"), Some(("", "asdf.txt")) ); - assert_eq!(chop_basename("asdf/"), Some(("", "asdf")) ); - assert_eq!(chop_basename("/asdf/"), Some(("/", "asdf")) ); - assert_eq!(chop_basename("a///b"), Some(("a///", "b")) ); - assert_eq!(chop_basename("a///b//"), Some(("a///", "b")) ); - assert_eq!(chop_basename("/a///b//"), Some(("/a///", "b")) ); - assert_eq!(chop_basename("/a///b//"), Some(("/a///", "b")) ); + assert_eq!(chop_basename_str(""), None ); + assert_eq!(chop_basename_str("/"), None ); + assert_eq!(chop_basename_str("."), Some(("", ".")) ); + assert_eq!(chop_basename_str("asdf/asdf"), Some(("asdf/", "asdf")) ); + assert_eq!(chop_basename_str("asdf.txt"), Some(("", "asdf.txt")) ); + assert_eq!(chop_basename_str("asdf/"), Some(("", "asdf")) ); + assert_eq!(chop_basename_str("/asdf/"), Some(("/", "asdf")) ); + assert_eq!(chop_basename_str("a///b"), Some(("a///", "b")) ); + assert_eq!(chop_basename_str("a///b//"), Some(("a///", "b")) ); + assert_eq!(chop_basename_str("/a///b//"), Some(("/a///", "b")) ); + assert_eq!(chop_basename_str("/a///b//"), Some(("/a///", "b")) ); - assert_eq!(chop_basename("./../..///.../..//"), Some(("./../..///.../", ".."))); + assert_eq!(chop_basename_str("./../..///.../..//"), Some(("./../..///.../", ".."))); } +#[test] +fn it_chops_non_utf8_paths() { + assert_eq!(chop_basename(b"\xff/\xfe\xfd/"), Some((&b"\xff/"[..], &b"\xfe\xfd"[..]))); +} diff --git a/src/cleanpath_aggressive.rs b/src/cleanpath_aggressive.rs index d976301..33e6bed 100644 --- a/src/cleanpath_aggressive.rs +++ b/src/cleanpath_aggressive.rs @@ -1,19 +1,19 @@ use std::borrow::Cow; -use prepend_prefix::prepend_prefix; -use basename::basename; -use chop_basename::chop_basename; -use path_parsing::{SEP_STR, contains_sep}; +use crate::prepend_prefix::prepend_prefix; +use crate::basename::basename; +use crate::chop_basename::chop_basename; +use crate::path_parsing::{SEP_BYTES, contains_sep}; -pub fn cleanpath_aggressive(path: &str) -> Cow { - let mut names: Vec<&str> = vec![]; +pub fn cleanpath_aggressive(path: &[u8]) -> Cow<'_, [u8]> { + let mut names: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((ref p, ref base)) = chop_basename(&prefix) { + while let Some((p, base)) = chop_basename(prefix) { prefix = p; - match base.as_ref() { - "." => {} - ".." => names.push(base), + match base { + b"." => {} + b".." => names.push(base), _ => { - if names.last() == Some(&"..") { + if names.last() == Some(&&b".."[..]) { names.pop(); } else { names.push(base); @@ -27,48 +27,53 @@ pub fn cleanpath_aggressive(path: &str) -> Cow { // pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR // ``` // - if contains_sep(basename(&prefix, "").as_bytes()) { - let len = names.iter().rposition(|&c| c != "..").map_or(0, |pos| pos + 1); + if contains_sep(basename(prefix, b"")) { + let len = names.iter().rposition(|&c| c != b"..").map_or(0, |pos| pos + 1); names.truncate(len); } names.reverse(); - prepend_prefix(&prefix, &names.join(&SEP_STR)) + prepend_prefix(prefix, &names.join(SEP_BYTES)) +} + +#[cfg(test)] +fn cleanpath_aggressive_str(path: &str) -> String { + String::from_utf8(cleanpath_aggressive(path.as_bytes()).into_owned()).unwrap() } #[test] fn it_aggressively_cleans_the_path() { - assert_eq!(cleanpath_aggressive("/") , "/"); - assert_eq!(cleanpath_aggressive("") , "."); - assert_eq!(cleanpath_aggressive(".") , "."); - assert_eq!(cleanpath_aggressive("..") , ".."); - assert_eq!(cleanpath_aggressive("a") , "a"); - assert_eq!(cleanpath_aggressive("/.") , "/"); - assert_eq!(cleanpath_aggressive("/..") , "/"); - assert_eq!(cleanpath_aggressive("/a") , "/a"); - assert_eq!(cleanpath_aggressive("./") , "."); - assert_eq!(cleanpath_aggressive("../") , ".."); - assert_eq!(cleanpath_aggressive("a/") , "a"); - assert_eq!(cleanpath_aggressive("a//b") , "a/b"); - assert_eq!(cleanpath_aggressive("a/.") , "a"); - assert_eq!(cleanpath_aggressive("a/./") , "a"); - assert_eq!(cleanpath_aggressive("a/..") , "."); - assert_eq!(cleanpath_aggressive("a/../") , "."); - assert_eq!(cleanpath_aggressive("/a/.") , "/a"); - assert_eq!(cleanpath_aggressive("./..") , ".."); - assert_eq!(cleanpath_aggressive("../.") , ".."); - assert_eq!(cleanpath_aggressive("./../") , ".."); - assert_eq!(cleanpath_aggressive(".././") , ".."); - assert_eq!(cleanpath_aggressive("/./..") , "/"); - assert_eq!(cleanpath_aggressive("/../.") , "/"); - assert_eq!(cleanpath_aggressive("/./../") , "/"); - assert_eq!(cleanpath_aggressive("/.././") , "/"); - assert_eq!(cleanpath_aggressive("a/b/c") , "a/b/c"); - assert_eq!(cleanpath_aggressive("./b/c") , "b/c"); - assert_eq!(cleanpath_aggressive("a/./c") , "a/c"); - assert_eq!(cleanpath_aggressive("a/b/.") , "a/b"); - assert_eq!(cleanpath_aggressive("a/../.") , "."); - assert_eq!(cleanpath_aggressive("/../.././../a") , "/a"); - assert_eq!(cleanpath_aggressive("a/b/../../../../c/../d"), "../../d"); + assert_eq!(cleanpath_aggressive_str("/") , "/"); + assert_eq!(cleanpath_aggressive_str("") , "."); + assert_eq!(cleanpath_aggressive_str(".") , "."); + assert_eq!(cleanpath_aggressive_str("..") , ".."); + assert_eq!(cleanpath_aggressive_str("a") , "a"); + assert_eq!(cleanpath_aggressive_str("/.") , "/"); + assert_eq!(cleanpath_aggressive_str("/..") , "/"); + assert_eq!(cleanpath_aggressive_str("/a") , "/a"); + assert_eq!(cleanpath_aggressive_str("./") , "."); + assert_eq!(cleanpath_aggressive_str("../") , ".."); + assert_eq!(cleanpath_aggressive_str("a/") , "a"); + assert_eq!(cleanpath_aggressive_str("a//b") , "a/b"); + assert_eq!(cleanpath_aggressive_str("a/.") , "a"); + assert_eq!(cleanpath_aggressive_str("a/./") , "a"); + assert_eq!(cleanpath_aggressive_str("a/..") , "."); + assert_eq!(cleanpath_aggressive_str("a/../") , "."); + assert_eq!(cleanpath_aggressive_str("/a/.") , "/a"); + assert_eq!(cleanpath_aggressive_str("./..") , ".."); + assert_eq!(cleanpath_aggressive_str("../.") , ".."); + assert_eq!(cleanpath_aggressive_str("./../") , ".."); + assert_eq!(cleanpath_aggressive_str(".././") , ".."); + assert_eq!(cleanpath_aggressive_str("/./..") , "/"); + assert_eq!(cleanpath_aggressive_str("/../.") , "/"); + assert_eq!(cleanpath_aggressive_str("/./../") , "/"); + assert_eq!(cleanpath_aggressive_str("/.././") , "/"); + assert_eq!(cleanpath_aggressive_str("a/b/c") , "a/b/c"); + assert_eq!(cleanpath_aggressive_str("./b/c") , "b/c"); + assert_eq!(cleanpath_aggressive_str("a/./c") , "a/c"); + assert_eq!(cleanpath_aggressive_str("a/b/.") , "a/b"); + assert_eq!(cleanpath_aggressive_str("a/../.") , "."); + assert_eq!(cleanpath_aggressive_str("/../.././../a") , "/a"); + assert_eq!(cleanpath_aggressive_str("a/b/../../../../c/../d"), "../../d"); } // Future Windows Support diff --git a/src/cleanpath_conservative.rs b/src/cleanpath_conservative.rs index f4c7b87..ea5f3c4 100644 --- a/src/cleanpath_conservative.rs +++ b/src/cleanpath_conservative.rs @@ -1,17 +1,16 @@ use std::borrow::Cow; -use std::path::MAIN_SEPARATOR; -use prepend_prefix::prepend_prefix; -use basename::basename; -use dirname::dirname; -use chop_basename::chop_basename; -use path_parsing::{SEP_STR, contains_sep}; +use crate::prepend_prefix::prepend_prefix; +use crate::basename::basename; +use crate::dirname::dirname; +use crate::chop_basename::chop_basename; +use crate::path_parsing::{SEP, SEP_BYTES, contains_sep}; -pub fn cleanpath_conservative(path: &str) -> Cow { - let mut names: Vec<&str> = vec![]; +pub fn cleanpath_conservative(path: &[u8]) -> Cow<'_, [u8]> { + let mut names: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((ref p, ref base)) = chop_basename(&prefix) { + while let Some((p, base)) = chop_basename(prefix) { prefix = p; - if base != &"." { + if base != b"." { names.push(base); } } @@ -21,61 +20,107 @@ pub fn cleanpath_conservative(path: &str) -> Cow { // pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR // ``` // - if contains_sep(basename(&prefix, "").as_bytes()) { - let len = names.iter().rposition(|&c| c != "..").map_or(0, |pos| pos + 1); + if contains_sep(basename(prefix, b"")) { + let len = names.iter().rposition(|&c| c != b"..").map_or(0, |pos| pos + 1); names.truncate(len); } - let last_name = match names.first() { - Some(&name) => name, - None => return dirname(&prefix).into(), - }; + if names.is_empty() { + return dirname(prefix).into(); + } + names.reverse(); + if names.last() != Some(&&b".."[..]) && basename(path, b"") == b"." { + names.push(b"."); + } + let result = prepend_prefix(prefix, &names.join(SEP_BYTES)); + match names.last() { + Some(&b".") | Some(&b"..") => result, + _ if has_trailing_separator(path) => add_trailing_separator(result), + _ => result, + } +} - if last_name != ".." && basename(&path, "") == "." { - names.reverse(); - names.push("."); - } else if last_name != "." && last_name != ".." && - chop_basename(path).map(|(a, b)| a.len() + b.len()).unwrap() < path.len() { - return format!("{}{}", last_name, MAIN_SEPARATOR).into(); +pub fn has_trailing_separator(path: &[u8]) -> bool { + match chop_basename(path) { + Some((dirname, basename)) => dirname.len() + basename.len() < path.len(), + None => false, + } +} + +// Ruby: `File.basename(path + 'a') == 'a' ? path : File.join(path, '')` +pub fn add_trailing_separator(path: Cow<'_, [u8]>) -> Cow<'_, [u8]> { + if path.is_empty() || path.last() == Some(&SEP) { + path } else { - names.reverse(); + let mut path = path.into_owned(); + path.push(SEP); + path.into() } - prepend_prefix(&prefix, &names.join(&SEP_STR)) +} + +#[cfg(test)] +fn cleanpath_conservative_str(path: &str) -> String { + String::from_utf8(cleanpath_conservative(path.as_bytes()).into_owned()).unwrap() +} + +#[test] +fn it_keeps_the_whole_path_with_a_trailing_separator() { + assert_eq!(cleanpath_conservative_str("a/b/"), "a/b/"); + assert_eq!(cleanpath_conservative_str("/a/b//"), "/a/b/"); + assert_eq!(cleanpath_conservative_str("a/./b/"), "a/b/"); + assert_eq!(cleanpath_conservative_str("a/b/../"), "a/b/.."); +} + +#[test] +fn it_adds_trailing_separators() { + assert_eq!(add_trailing_separator(Cow::Borrowed(&b""[..])), &b""[..]); + assert_eq!(add_trailing_separator(Cow::Borrowed(&b"/"[..])), &b"/"[..]); + assert_eq!(add_trailing_separator(Cow::Borrowed(&b"a"[..])), &b"a/"[..]); + assert_eq!(add_trailing_separator(Cow::Borrowed(&b"a/"[..])), &b"a/"[..]); +} + +#[test] +fn it_detects_trailing_separators() { + assert!(!has_trailing_separator(b"")); + assert!(!has_trailing_separator(b"/")); + assert!(!has_trailing_separator(b"a")); + assert!(has_trailing_separator(b"a/")); + assert!(has_trailing_separator(b"/a//")); } #[test] fn it_conservatively_cleans_the_path() { - assert_eq!(cleanpath_conservative("/"), "/"); - assert_eq!(cleanpath_conservative(""), "."); - assert_eq!(cleanpath_conservative("."), "."); - assert_eq!(cleanpath_conservative(".."), ".."); - assert_eq!(cleanpath_conservative("a"), "a"); - assert_eq!(cleanpath_conservative("/."), "/"); - assert_eq!(cleanpath_conservative("/.."), "/"); - assert_eq!(cleanpath_conservative("/a"), "/a"); - assert_eq!(cleanpath_conservative("./"), "."); - assert_eq!(cleanpath_conservative("../"), ".."); - assert_eq!(cleanpath_conservative("a/"), "a/"); - assert_eq!(cleanpath_conservative("a//b"), "a/b"); - assert_eq!(cleanpath_conservative("a/."), "a/."); - assert_eq!(cleanpath_conservative("a/./"), "a/."); - assert_eq!(cleanpath_conservative("a/../"), "a/.."); - assert_eq!(cleanpath_conservative("/a/."), "/a/."); - assert_eq!(cleanpath_conservative("./.."), ".."); - assert_eq!(cleanpath_conservative("../."), ".."); - assert_eq!(cleanpath_conservative("./../"), ".."); - assert_eq!(cleanpath_conservative(".././"), ".."); - assert_eq!(cleanpath_conservative("/./.."), "/"); - assert_eq!(cleanpath_conservative("/../."), "/"); - assert_eq!(cleanpath_conservative("/./../"), "/"); - assert_eq!(cleanpath_conservative("/.././"), "/"); - assert_eq!(cleanpath_conservative("a/b/c"), "a/b/c"); - assert_eq!(cleanpath_conservative("./b/c"), "b/c"); - assert_eq!(cleanpath_conservative("a/./c"), "a/c"); - assert_eq!(cleanpath_conservative("a/b/."), "a/b/."); - assert_eq!(cleanpath_conservative("a/../."), "a/.."); - assert_eq!(cleanpath_conservative("/../.././../a"), "/a"); - assert_eq!(cleanpath_conservative("a/b/../../../../c/../d"), "a/b/../../../../c/../d"); + assert_eq!(cleanpath_conservative_str("/"), "/"); + assert_eq!(cleanpath_conservative_str(""), "."); + assert_eq!(cleanpath_conservative_str("."), "."); + assert_eq!(cleanpath_conservative_str(".."), ".."); + assert_eq!(cleanpath_conservative_str("a"), "a"); + assert_eq!(cleanpath_conservative_str("/."), "/"); + assert_eq!(cleanpath_conservative_str("/.."), "/"); + assert_eq!(cleanpath_conservative_str("/a"), "/a"); + assert_eq!(cleanpath_conservative_str("./"), "."); + assert_eq!(cleanpath_conservative_str("../"), ".."); + assert_eq!(cleanpath_conservative_str("a/"), "a/"); + assert_eq!(cleanpath_conservative_str("a//b"), "a/b"); + assert_eq!(cleanpath_conservative_str("a/."), "a/."); + assert_eq!(cleanpath_conservative_str("a/./"), "a/."); + assert_eq!(cleanpath_conservative_str("a/../"), "a/.."); + assert_eq!(cleanpath_conservative_str("/a/."), "/a/."); + assert_eq!(cleanpath_conservative_str("./.."), ".."); + assert_eq!(cleanpath_conservative_str("../."), ".."); + assert_eq!(cleanpath_conservative_str("./../"), ".."); + assert_eq!(cleanpath_conservative_str(".././"), ".."); + assert_eq!(cleanpath_conservative_str("/./.."), "/"); + assert_eq!(cleanpath_conservative_str("/../."), "/"); + assert_eq!(cleanpath_conservative_str("/./../"), "/"); + assert_eq!(cleanpath_conservative_str("/.././"), "/"); + assert_eq!(cleanpath_conservative_str("a/b/c"), "a/b/c"); + assert_eq!(cleanpath_conservative_str("./b/c"), "b/c"); + assert_eq!(cleanpath_conservative_str("a/./c"), "a/c"); + assert_eq!(cleanpath_conservative_str("a/b/."), "a/b/."); + assert_eq!(cleanpath_conservative_str("a/../."), "a/.."); + assert_eq!(cleanpath_conservative_str("/../.././../a"), "/a"); + assert_eq!(cleanpath_conservative_str("a/b/../../../../c/../d"), "a/b/../../../../c/../d"); // Future Windows Support // diff --git a/src/debug.rs b/src/debug.rs deleted file mode 100644 index f802632..0000000 --- a/src/debug.rs +++ /dev/null @@ -1,29 +0,0 @@ -use rutie::{ - RString, - AnyObject, - Object, -}; - -#[derive(Debug)] -pub struct RubyDebugInfo { - object_id: String, - class: String, - inspect: String, -} - -impl From for RubyDebugInfo { - fn from(ao: AnyObject) -> Self { - let object_id = ao.send("object_id", &[]).send("to_s", &[]). - try_convert_to::().unwrap_or(RString::new_utf8("Failed to get object_id!")).to_string(); - let class = ao.send("class", &[]).send("to_s", &[]). - try_convert_to::().unwrap_or(RString::new_utf8("Failed to get class!")).to_string(); - let inspect = ao.send("inspect", &[]). - try_convert_to::().unwrap_or(RString::new_utf8("Failed to get inspect!")).to_string(); - - RubyDebugInfo { - object_id: object_id, - class: class, - inspect: inspect, - } - } -} diff --git a/src/dirname.rs b/src/dirname.rs index 490835f..fe7c3ea 100644 --- a/src/dirname.rs +++ b/src/dirname.rs @@ -1,66 +1,80 @@ -use std::str; -use path_parsing::{find_last_sep_pos, find_last_non_sep_pos}; +use crate::path_parsing::{find_last_sep_pos, find_last_non_sep_pos, SEP_BYTES}; -pub fn dirname(path: &str) -> &str { - let bytes = path.as_bytes(); - let mut last_slash_pos = match find_last_sep_pos(bytes) { +pub fn dirname(path: &[u8]) -> &[u8] { + let mut last_slash_pos = match find_last_sep_pos(path) { Some(pos) => pos, - _ => return ".", + _ => return b".", }; // Skip trailing slashes. - if last_slash_pos == bytes.len() - 1 { - let last_non_slash_pos = match find_last_non_sep_pos(&bytes[..last_slash_pos]) { + if last_slash_pos == path.len() - 1 { + let last_non_slash_pos = match find_last_non_sep_pos(&path[..last_slash_pos]) { Some(pos) => pos, - _ => return "/" + _ => return SEP_BYTES, }; - last_slash_pos = match find_last_sep_pos(&bytes[..last_non_slash_pos]) { + last_slash_pos = match find_last_sep_pos(&path[..last_non_slash_pos]) { Some(pos) => pos, - _ => return "." + _ => return b".", }; }; - if let Some(end) = find_last_non_sep_pos(&bytes[..last_slash_pos]) { + if let Some(end) = find_last_non_sep_pos(&path[..last_slash_pos]) { &path[..end + 1] } else { - "/" + SEP_BYTES } } +#[cfg(test)] +fn dirname_str(path: &str) -> &str { + std::str::from_utf8(dirname(path.as_bytes())).unwrap() +} + #[test] fn absolute() { - assert_eq!(dirname("/a/b///c"), "/a/b"); + assert_eq!(dirname_str("/a/b///c"), "/a/b"); } #[test] fn trailing_slashes_absolute() { - assert_eq!(dirname("/a/b///c//////"), "/a/b"); + assert_eq!(dirname_str("/a/b///c//////"), "/a/b"); } #[test] fn relative() { - assert_eq!(dirname("b///c"), "b"); + assert_eq!(dirname_str("b///c"), "b"); } #[test] fn trailing_slashes_relative() { - assert_eq!(dirname("b/c//"), "b"); + assert_eq!(dirname_str("b/c//"), "b"); } #[test] fn root() { - assert_eq!(dirname("//c"), "/"); + assert_eq!(dirname_str("//c"), "/"); } #[test] fn trailing_slashes_root() { - assert_eq!(dirname("//c//"), "/"); + assert_eq!(dirname_str("//c//"), "/"); } #[test] fn trailing_slashes_relative_root() { - assert_eq!(dirname("c//"), "."); + assert_eq!(dirname_str("c//"), "."); } #[test] fn returns_dot_for_empty_string() { - assert_eq!(dirname(""), "."); + assert_eq!(dirname_str(""), "."); +} + +#[test] +fn only_separators() { + assert_eq!(dirname_str("/"), "/"); + assert_eq!(dirname_str("///"), "/"); +} + +#[test] +fn non_utf8() { + assert_eq!(dirname(b"\xff\xfe/a"), b"\xff\xfe"); } diff --git a/src/extname.rs b/src/extname.rs index 36eb732..79b06a8 100644 --- a/src/extname.rs +++ b/src/extname.rs @@ -1,25 +1,93 @@ -use std::str; -use path_parsing::{SEP, find_last_non_sep_pos}; +use std::sync::atomic::{AtomicBool, Ordering}; -pub fn extname(path: &str) -> &str { - let end = match find_last_non_sep_pos(path.as_bytes()) { +use crate::path_parsing::{find_last_non_sep_pos, find_last_sep_pos}; + +// Ruby 2.7 changed `File.extname("foo.")` from `""` to `"."`. `FasterPath` +// follows the Ruby it is loaded into; see `Init_faster_pathname`. +static TRAILING_DOT_IS_EXTNAME: AtomicBool = AtomicBool::new(false); + +pub fn set_trailing_dot_is_extname(value: bool) { + TRAILING_DOT_IS_EXTNAME.store(value, Ordering::Relaxed); +} + +pub fn extname(path: &[u8]) -> &[u8] { + extname_with(path, TRAILING_DOT_IS_EXTNAME.load(Ordering::Relaxed)) +} + +// Ruby's `ruby_enc_find_extname` for non-Windows platforms. +fn extname_with(path: &[u8], trailing_dot_is_extname: bool) -> &[u8] { + // The last component, without trailing separators + let end = match find_last_non_sep_pos(path) { Some(pos) => pos + 1, - _ => return "", + None => return b"", }; - let bytes = &path.as_bytes()[..end]; - for (pos, c) in bytes.iter().enumerate().rev() { - match *c { - b'.' => { - let prev = bytes.get(pos - 1); - if pos == end - 1 || prev == None || prev == Some(&SEP) { - return ""; - } else { - return &path[pos..end] - }; + let start = find_last_sep_pos(&path[..end]).map_or(0, |pos| pos + 1); + let name = &path[start..end]; + + // Leading dots don't start an extension + let leading_dots = name.iter().take_while(|&&c| c == b'.').count(); + match name[leading_dots..].iter().rposition(|&c| c == b'.') { + None => b"", + Some(pos) => { + let dot = leading_dots + pos; + if dot + 1 == name.len() && !trailing_dot_is_extname { + b"" + } else { + &name[dot..] } - SEP => return "", - _ => {} } - }; - "" + } +} + +#[cfg(test)] +fn extname_str(path: &str, trailing_dot_is_extname: bool) -> &str { + std::str::from_utf8(extname_with(path.as_bytes(), trailing_dot_is_extname)).unwrap() +} + +#[test] +fn it_finds_the_extension() { + for &trailing_dot in &[false, true] { + assert_eq!(extname_str("", trailing_dot), ""); + assert_eq!(extname_str("/", trailing_dot), ""); + assert_eq!(extname_str("a.rb", trailing_dot), ".rb"); + assert_eq!(extname_str("a.rb/", trailing_dot), ".rb"); + assert_eq!(extname_str("a.rb//", trailing_dot), ".rb"); + assert_eq!(extname_str("a/b.c.d", trailing_dot), ".d"); + assert_eq!(extname_str("a..b", trailing_dot), ".b"); + assert_eq!(extname_str(".a.b", trailing_dot), ".b"); + assert_eq!(extname_str("a/.rb", trailing_dot), ""); + assert_eq!(extname_str("a.b/c", trailing_dot), ""); + assert_eq!(extname_str(".bashrc", trailing_dot), ""); + assert_eq!(extname_str("..foo", trailing_dot), ""); + assert_eq!(extname_str("a/..b", trailing_dot), ""); + assert_eq!(extname_str("...a", trailing_dot), ""); + } +} + +#[test] +fn only_dots() { + for &trailing_dot in &[false, true] { + assert_eq!(extname_str(".", trailing_dot), ""); + assert_eq!(extname_str("..", trailing_dot), ""); + assert_eq!(extname_str("....", trailing_dot), ""); + assert_eq!(extname_str("/.", trailing_dot), ""); + assert_eq!(extname_str(".a/", trailing_dot), ""); + } +} + +#[test] +fn trailing_dot() { + assert_eq!(extname_str("foo.", false), ""); + assert_eq!(extname_str(".foo.", false), ""); + assert_eq!(extname_str("foo..", false), ""); + assert_eq!(extname_str("a./", false), ""); + assert_eq!(extname_str("foo.", true), "."); + assert_eq!(extname_str(".foo.", true), "."); + assert_eq!(extname_str("foo..", true), "."); + assert_eq!(extname_str("a./", true), "."); +} + +#[test] +fn non_utf8() { + assert_eq!(extname_with(b"\xff.\xfe", false), b".\xfe"); } diff --git a/src/helpers.rs b/src/helpers.rs index e360eb3..ed6f8a9 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,50 +1,11 @@ -use rutie::{RString, Object, Class, AnyObject}; -use debug::RubyDebugInfo; -use rutie; - -type MaybeString = Result; - -pub trait TryFrom: Sized { - type Error; - - fn try_from(value: T) -> Result; -} - #[cfg(windows)] #[inline] -pub fn is_same_path(a: &str, b: &str) -> bool { - a.to_uppercase() == b.to_uppercase() +pub fn is_same_path(a: &[u8], b: &[u8]) -> bool { + a.eq_ignore_ascii_case(b) } #[cfg(not(windows))] #[inline] -pub fn is_same_path(a: &str, b: &str) -> bool { +pub fn is_same_path(a: &[u8], b: &[u8]) -> bool { a == b } - -pub fn anyobject_to_string(item: AnyObject) -> Result { - let result = &item; - if Class::from_existing("String").case_equals(result) { - return Ok(RString::from(result.value()).to_string()) - } - - if Class::from_existing("Pathname").case_equals(result) { - return Ok(result.instance_variable_get("@path"). - try_convert_to::(). - unwrap_or(RString::new_utf8("")). - to_string()) - } - - if result.respond_to("to_path") { - return Ok(RString::from(result.send("to_path", &[]).value()).to_string()) - } - - Ok(RString::from(result.send("to_s", &[]).value()).to_string()) -} - -pub fn to_str(maybe_string: &MaybeString) -> &str { - match maybe_string { - &Ok(ref rutie_string) => rutie_string.to_str(), - &Err(_) => "", - } -} diff --git a/src/lib.rs b/src/lib.rs index 4f13c63..345d43d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,15 +4,10 @@ // http://apache.org/licenses/LICENSE-2.0> or the MIT license , at your option. This file may not be // copied, modified, or distributed except according to those terms. -#[macro_use] -extern crate rutie; +#![forbid(unsafe_op_in_unsafe_fn)] #[macro_use] -extern crate lazy_static; - -module!(FasterPath); - -mod debug; +mod ruby; mod helpers; mod pathname; mod basename; @@ -21,148 +16,104 @@ mod cleanpath_aggressive; mod cleanpath_conservative; mod dirname; mod extname; -mod pathname_sys; mod plus; mod prepend_prefix; pub mod rust_arch_bits; -mod memrnchr; mod path_parsing; mod relative_path_from; -use pathname::Pathname; - -use rutie::{Module, Object, RString, Boolean, AnyObject, VM}; - -use pathname_sys::*; - -methods!( - FasterPath, - _itself, +use rutie::{Class, Module, Object, RString}; - fn pub_add_trailing_separator(pth: RString) -> RString { - pathname::pn_add_trailing_separator(pth) +ruby_methods! { + fn pub_add_trailing_separator(arguments) { + pathname::pn_add_trailing_separator(arguments) } - fn pub_is_absolute(pth: RString) -> Boolean { - pathname::pn_is_absolute(pth) + fn pub_is_absolute(arguments) { + pathname::pn_is_absolute(arguments) } - // fn r_ascend(){} - - fn pub_basename(pth: RString, ext: RString) -> RString { - pathname::pn_basename(pth, ext) + fn pub_basename(arguments) { + pathname::pn_basename(arguments) } - fn pub_children(pth: RString, with_dir: Boolean) -> AnyObject { - pathname::pn_children(pth, with_dir). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_children(arguments) { + pathname::pn_children(arguments) } - fn pub_children_compat(pth: RString, with_dir: Boolean) -> AnyObject { - pathname::pn_children_compat(pth, with_dir). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_children_compat(arguments) { + pathname::pn_children_compat(arguments) } - fn pub_chop_basename(pth: RString) -> AnyObject { - pathname::pn_chop_basename(pth) + fn pub_chop_basename(arguments) { + pathname::pn_chop_basename(arguments) } - // fn r_cleanpath(){ pub_cleanpath(r_to_path()) } - // fn pub_cleanpath(pth: RString){} - - fn pub_cleanpath_aggressive(pth: RString) -> RString { - pathname::pn_cleanpath_aggressive(pth) + fn pub_cleanpath_aggressive(arguments) { + pathname::pn_cleanpath_aggressive(arguments) } - fn pub_cleanpath_conservative(pth: RString) -> RString { - pathname::pn_cleanpath_conservative(pth) + fn pub_cleanpath_conservative(arguments) { + pathname::pn_cleanpath_conservative(arguments) } - fn pub_del_trailing_separator(pth: RString) -> RString { - pathname::pn_del_trailing_separator(pth) + fn pub_del_trailing_separator(arguments) { + pathname::pn_del_trailing_separator(arguments) } - // fn r_descend(){} - - fn pub_is_directory(pth: RString) -> Boolean { - pathname::pn_is_directory(pth) + fn pub_is_directory(arguments) { + pathname::pn_is_directory(arguments) } - fn pub_dirname(pth: RString) -> RString { - pathname::pn_dirname(pth) + fn pub_dirname(arguments) { + pathname::pn_dirname(arguments) } - // fn r_each_child(){} - // fn pub_each_child(){} - - // fn pub_each_filename(pth: RString) -> NilClass { - // pathname::pn_each_filename(pth) - // } - // pub_entries returns an array of String objects - fn pub_entries(pth: RString) -> AnyObject { - pathname::pn_entries(pth). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_entries(arguments) { + pathname::pn_entries(arguments) } // pub_entries_compat returns an array of Pathname objects - fn pub_entries_compat(pth: RString) -> AnyObject { - pathname::pn_entries_compat(pth). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_entries_compat(arguments) { + pathname::pn_entries_compat(arguments) } - fn pub_extname(pth: RString) -> RString { - pathname::pn_extname(pth) + fn pub_extname(arguments) { + pathname::pn_extname(arguments) } - // fn r_find(ignore_error: Boolean){} - // fn pub_find(pth: RString ,ignore_error: Boolean){} - - fn pub_has_trailing_separator(pth: RString) -> Boolean { - pathname::pn_has_trailing_separator(pth) + fn pub_has_trailing_separator(arguments) { + pathname::pn_has_trailing_separator(arguments) } - // fn pub_mkpath(pth: RString) -> NilClass { - // pathname::pn_mkpath(pth) - // } - - // fn r_is_mountpoint(){ pub_is_mountpount(r_to_path()) } - // fn pub_is_mountpoint(pth: RString){} - - // fn r_parent(){ pub_parent(r_to_path()) } - // fn pub_parent(pth: RString){} - - // also need impl + - fn pub_plus(pth1: RString, pth2: RString) -> RString { - pathname::pn_plus(pth1, pth2) + fn pub_join(arguments) { + pathname::pn_join(arguments) } - // fn r_prepend_prefix(prefix: RString, relpath: RString){} - - fn pub_is_relative(pth: RString) -> Boolean { - pathname::pn_is_relative(pth) + fn pub_plus(arguments) { + pathname::pn_plus(arguments) } - // fn r_root(){ pub_root(r_to_path()) } - // fn pub_root(pth: RString){} - - // fn r_split_names(pth: RString){} - - fn pub_relative_path_from(itself: RString, base_directory: AnyObject) -> Pathname { - let to_string = |i: AnyObject| { RString::from(i.send("to_s", &[]).value()) }; - - pathname::pn_relative_path_from(itself, base_directory.map(to_string)). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_is_relative(arguments) { + pathname::pn_is_relative(arguments) } - // fn pub_rmtree(pth: RString) -> NilClass { - // pathname::pn_rmtree(pth) - // } -); + fn pub_relative_path_from(arguments) { + pathname::pn_relative_path_from(arguments) + } +} #[allow(non_snake_case)] #[no_mangle] pub extern "C" fn Init_faster_pathname() { + let trailing_dot_extname = Class::file(). + protect_send("extname", &[RString::new_utf8("a.").into()]). + ok(). + and_then(|extname| extname.try_convert_to::().ok()). + map_or(false, |extname| extname.to_bytes_unchecked() == b"."); + extname::set_trailing_dot_is_extname(trailing_dot_extname); + Module::from_existing("FasterPath").define(|itself| { itself.def_self("absolute?", pub_is_absolute); itself.def_self("add_trailing_separator", pub_add_trailing_separator); diff --git a/src/memrnchr.rs b/src/memrnchr.rs deleted file mode 100644 index d9a8f27..0000000 --- a/src/memrnchr.rs +++ /dev/null @@ -1,70 +0,0 @@ -// The code below is based on the fallback `memrchr` implementation from the respective crate. -// -// We use this mainly to skip repeated `/`. If there is only one slash, `memrnchr` performs the same -// as a naive version (e.g. `rposition`). However, it is much faster in pathological cases. - -use std::mem::size_of; - -// Returns the byte offset of the last byte that is NOT equal to the given one. -#[inline(always)] -pub fn memrnchr(x: u8, text: &[u8]) -> Option { - // Scan for a single byte value by reading two `usize` words at a time. - // - // Split `text` in three parts - // - unaligned tail, after the last word aligned address in text - // - body, scan by 2 words at a time - // - the first remaining bytes, < 2 word size - let len = text.len(); - let ptr = text.as_ptr(); - - // search to an aligned boundary - let end_align = (ptr as usize + len) & (size_of::() - 1); - let mut offset; - if end_align > 0 { - offset = if end_align >= len { 0 } else { len - end_align }; - if let Some(index) = memrnchr_naive(x, &text[offset..]) { - return Some(offset + index); - } - } else { - offset = len; - } - - // search the body of the text - let repeated_x = repeat_byte(x); - while offset >= 2 * size_of::() { - debug_assert_eq!((ptr as usize + offset) % size_of::(), 0); - unsafe { - let u = *(ptr.offset(offset as isize - 2 * size_of::() as isize) as *const usize); - let v = *(ptr.offset(offset as isize - size_of::() as isize) as *const usize); - if u & repeated_x != usize::max_value() || v & repeated_x != usize::max_value() { - break; - } - } - offset -= 2 * size_of::(); - } - - // find the byte before the point the body loop stopped - memrnchr_naive(x, &text[..offset]) -} - -#[inline(always)] -fn memrnchr_naive(x: u8, text: &[u8]) -> Option { - text.iter().rposition(|c| *c != x) -} - -#[cfg(target_pointer_width = "32")] -#[inline] -fn repeat_byte(b: u8) -> usize { - let mut rep = (b as usize) << 8 | b as usize; - rep = rep << 16 | rep; - rep -} - -#[cfg(target_pointer_width = "64")] -#[inline] -fn repeat_byte(b: u8) -> usize { - let mut rep = (b as usize) << 8 | b as usize; - rep = rep << 16 | rep; - rep = rep << 32 | rep; - rep -} diff --git a/src/path_parsing.rs b/src/path_parsing.rs index 2c90516..065b793 100644 --- a/src/path_parsing.rs +++ b/src/path_parsing.rs @@ -1,14 +1,8 @@ -extern crate memchr; - -use self::memchr::{memchr, memrchr}; -use memrnchr::memrnchr; +use memchr::{memchr, memrchr}; use std::path::MAIN_SEPARATOR; -use std::str; pub const SEP: u8 = MAIN_SEPARATOR as u8; -lazy_static! { - pub static ref SEP_STR: &'static str = str::from_utf8(&[SEP]).unwrap(); -} +pub const SEP_BYTES: &[u8] = &[SEP]; // Returns the byte offset of the last byte that equals MAIN_SEPARATOR. #[inline(always)] @@ -19,11 +13,23 @@ pub fn find_last_sep_pos(bytes: &[u8]) -> Option { // Returns the byte offset of the last byte that is not MAIN_SEPARATOR. #[inline(always)] pub fn find_last_non_sep_pos(bytes: &[u8]) -> Option { - memrnchr(SEP, bytes) + bytes.iter().rposition(|&b| b != SEP) } // Whether the given byte sequence contains a MAIN_SEPARATOR. #[inline(always)] pub fn contains_sep(bytes: &[u8]) -> bool { - memchr(SEP, bytes) != None + memchr(SEP, bytes).is_some() +} + +#[test] +fn it_finds_separators() { + assert_eq!(find_last_sep_pos(b""), None); + assert_eq!(find_last_sep_pos(b"a/b/c"), Some(3)); + assert_eq!(find_last_non_sep_pos(b""), None); + assert_eq!(find_last_non_sep_pos(b"///"), None); + assert_eq!(find_last_non_sep_pos(b"a///"), Some(0)); + assert_eq!(find_last_non_sep_pos(b"a/b/c/d/e/f/g/h/i/j/k//////////////////////"), Some(20)); + assert!(contains_sep(b"a/b")); + assert!(!contains_sep(b"ab")); } diff --git a/src/pathname.rs b/src/pathname.rs index b419500..682f4b8 100644 --- a/src/pathname.rs +++ b/src/pathname.rs @@ -1,325 +1,363 @@ -use helpers::*; -use basename; -use chop_basename; -use cleanpath_aggressive; -use cleanpath_conservative; -use dirname; -use extname; -use plus; -use relative_path_from; -use debug; -use helpers::{TryFrom, to_str}; -use path_parsing::{SEP, find_last_non_sep_pos}; - -use rutie; -use rutie::{ - RString, - Boolean, - Array, - AnyObject, - NilClass, - Object, - Class, - VerifiedObject, - Exception as Exc, - AnyException as Exception, -}; -use rutie::types::{Value, ValueType}; use std::borrow::Cow; -use std::path::{MAIN_SEPARATOR, Path}; +use std::ffi::OsStr; use std::fs; +use std::io; +use std::path::Path; + +use rutie::{AnyException, AnyObject, Array, Boolean, Class, NilClass, Object, RString}; + +use crate::basename; +use crate::chop_basename; +use crate::cleanpath_aggressive; +use crate::cleanpath_conservative::{self, add_trailing_separator, has_trailing_separator}; +use crate::dirname; +use crate::extname; +use crate::path_parsing::{SEP, SEP_BYTES, find_last_non_sep_pos}; +use crate::plus; +use crate::relative_path_from::{self, RelativePathError}; +use crate::ruby::{ + argument_error, check_max_arguments, error, new_pathname, path_like_to_string, pathname_class, + truthy_argument, EncodingId, EncodingOf, PathArgument, PathString, RubyResult, +}; -type MaybeArray = Result; -type MaybeString = Result; -type MaybeBoolean = Result; - -pub struct Pathname { - value: Value -} - -impl Pathname { - pub fn new(path: &str) -> Pathname { - let arguments = [RString::new_utf8(path).to_any_object()]; - let instance = Class::from_existing("Pathname").new_instance(&arguments); - - Pathname { value: instance.value() } +pub fn pn_add_trailing_separator(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + if argument.is_missing() { + return Err(implicit_conversion_error(arguments.first())); } - - pub fn to_any_object(&self) -> AnyObject { - AnyObject::from(self.value()) + let path = argument.path()?; + match (add_trailing_separator(Cow::Borrowed(path.bytes())), argument.string()) { + (Cow::Borrowed(_), Some(string)) => Ok(string.to_any_object()), + (result, _) => Ok(path.to_ruby(&result).into()), } } -impl From for Pathname { - fn from(value: Value) -> Self { - Pathname { value } - } +pub fn pn_is_absolute(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(Boolean::new(path.bytes().first() == Some(&SEP)).into()) } -impl TryFrom for Pathname { - type Error = debug::RubyDebugInfo; - fn try_from(obj: AnyObject) -> Result { - if Class::from_existing("String").case_equals(&obj) { - Ok(Pathname::new(&RString::from(obj.value()).to_str())) - } else if Class::from_existing("Pathname").case_equals(&obj) { - Ok(Pathname::from(obj.value())) - } else if obj.respond_to("to_path") { - Ok(Pathname::new(&RString::from(obj.send("to_path", &[]).value()).to_str())) - } else { - Err(Self::Error::from(obj)) - } - } +pub fn pn_basename(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let ext_argument = PathArgument::new(arguments, 1)?; + let ext = ext_argument.path()?; + let basename = basename::last_component(path.bytes()); + let end = basename::ext_end(basename, ext.bytes()); + // Like Ruby, only remove an extension that starts a character. + let result = if path.is_char_boundary(basename, end) { &basename[..end] } else { basename }; + Ok(path.to_ruby(result).into()) } -impl Object for Pathname { - #[inline] - fn value(&self) -> Value { - self.value - } +pub fn pn_children(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path_or(b".")?; + let with_directory = truthy_argument(arguments, 1, true) && path.bytes() != b"."; + let error_path = error_path(&argument, "."); + children(&path, with_directory, &error_path, |entry| Ok(entry.into())) } -impl VerifiedObject for Pathname { - fn is_correct_type(object: &T) -> bool { - object.value().ty() == ValueType::Class && - Class::from_existing("Pathname").case_equals(object) - } +pub fn pn_children_compat(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let with_directory = truthy_argument(arguments, 1, true) && path.bytes() != b"."; + let error_path = error_path(&argument, ""); + let pathname = pathname_class()?; + children(&path, with_directory, &error_path, |entry| new_pathname(&pathname, entry)) +} - fn error_message() -> &'static str { - "Error converting to Pathname" +fn children(path: &PathString, with_directory: bool, error_path: &AnyObject, mut wrap: F) -> RubyResult + where F: FnMut(RString) -> RubyResult { + let entries = read_dir(path, error_path)?; + let filesystem = EncodingId::filesystem(); + let mut array = Array::with_capacity(entries.size_hint().0); + for entry in entries { + let entry = entry.map_err(|e| system_call_error(&e, error_path, "dir_read"))?; + let file_name = entry.file_name(); + let name = os_bytes(&file_name); + let string = if with_directory { + // Encoded like Ruby's `path + name`, or as the file name if they don't mix. + let encoding = EncodingOf::new(path).merge_with(EncodingOf::of_bytes(&name, filesystem)). + map_or(filesystem, |encoding| encoding.encoding); + encoding.new_string(&os_bytes(entry.path().as_os_str())) + } else { + filesystem.new_string(&name) + }; + array.push(wrap(string)?); } + Ok(array.into()) } -pub fn pn_add_trailing_separator(pth: MaybeString) -> RString { - let p = pth.unwrap(); - let x = format!("{}{}", p.to_str(), "a"); - match x.rsplit_terminator(MAIN_SEPARATOR).next() { - Some("a") => p, - _ => RString::new_utf8(format!("{}{}", p.to_str(), MAIN_SEPARATOR).as_str()) +pub fn pn_chop_basename(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + match chop_basename::chop_basename(path.bytes()) { + Some((dirname, basename)) => { + let mut array = Array::with_capacity(2); + array.push(path.to_ruby(dirname)); + array.push(path.to_ruby(basename)); + Ok(array.into()) + }, + None => Ok(NilClass::new().into()), } } -pub fn pn_is_absolute(pth: MaybeString) -> Boolean { - Boolean::new(to_str(&pth).as_bytes().get(0) == Some(&SEP)) +pub fn pn_cleanpath_aggressive(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(&cleanpath_aggressive::cleanpath_aggressive(path.bytes())).into()) } -// pub fn pn_ascend(){} - -pub fn pn_basename(pth: MaybeString, ext: MaybeString) -> RString { - RString::new_utf8(basename::basename(to_str(&pth), to_str(&ext))) +pub fn pn_cleanpath_conservative(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(&cleanpath_conservative::cleanpath_conservative(path.bytes())).into()) } -pub fn pn_children(pth: MaybeString, with_dir: MaybeBoolean) -> Result { - let path = pth.unwrap_or(RString::new_utf8(".")); - let path = path.to_str(); - - if let Ok(entries) = fs::read_dir(path) { - let mut with_directory = with_dir.unwrap_or(Boolean::new(true)).to_bool(); - if path == "." { - with_directory = false; - } - - let mut arr = Array::with_capacity(entries.size_hint().1.unwrap_or(0)); - for entry in entries { - if with_directory { - match entry { - Ok(v) => { arr.push(RString::new_utf8(v.path().to_str().unwrap())); }, - _ => {} - }; - } else { - match entry { - Ok(v) => { arr.push(RString::new_utf8(v.file_name().to_str().unwrap())); }, - _ => {} - }; - } - } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) +pub fn pn_del_trailing_separator(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let bytes = path.bytes(); + if bytes.is_empty() { + return Ok(path.to_ruby(b"").into()); } -} - -pub fn pn_children_compat(pth: MaybeString, with_dir: MaybeBoolean) -> Result { - let path = to_str(&pth); - - if let Ok(entries) = fs::read_dir(path) { - let mut with_directory = with_dir.unwrap_or(Boolean::new(true)).to_bool(); - if path == "." { - with_directory = false; - } - - let mut arr = Array::with_capacity(entries.size_hint().1.unwrap_or(0)); - for entry in entries { - if with_directory { - if let Ok(v) = entry { - arr.push(Pathname::new(v.path().to_str().unwrap())); - }; - } else { - if let Ok(v) = entry { - arr.push(Pathname::new(v.file_name().to_str().unwrap())); - }; - } - } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) + let pos = match find_last_non_sep_pos(bytes) { + Some(pos) => pos, + None => return Ok(path.to_ruby(SEP_BYTES).into()), + }; + match argument.string() { + Some(string) if pos == bytes.len() - 1 => Ok(string.to_any_object()), + _ => Ok(path.to_ruby(&bytes[..pos + 1]).into()), } } -pub fn pn_chop_basename(pth: MaybeString) -> AnyObject { - match chop_basename::chop_basename(to_str(&pth)) { - Some((dirname, basename)) => { - let mut arr = Array::with_capacity(2); - arr.push(RString::new_utf8(&dirname)); - arr.push(RString::new_utf8(&basename)); - arr.to_any_object() - }, - None => NilClass::new().to_any_object() - } +pub fn pn_is_directory(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let is_directory = os_path(path.bytes()).map_or(false, |os_path| os_path.is_dir()); + Ok(Boolean::new(is_directory).into()) } -// pub fn pn_cleanpath(pth: MaybeString){} +pub fn pn_dirname(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(dirname::dirname(path.bytes())).into()) +} -pub fn pn_cleanpath_aggressive(pth: MaybeString) -> RString { - RString::new_utf8(&cleanpath_aggressive::cleanpath_aggressive(to_str(&pth))) +// Returns an array of `String`s +pub fn pn_entries(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let error_path = error_path(&argument, ""); + entries(&path, &error_path, |entry| Ok(entry.into())) } -pub fn pn_cleanpath_conservative(pth: MaybeString) -> RString { - RString::new_utf8(&cleanpath_conservative::cleanpath_conservative(to_str(&pth))) +// Returns an array of `Pathname`s +pub fn pn_entries_compat(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let error_path = error_path(&argument, ""); + let pathname = pathname_class()?; + entries(&path, &error_path, |entry| new_pathname(&pathname, entry)) } -pub fn pn_del_trailing_separator(pth: MaybeString) -> RString { - { - let path = to_str(&pth); - if path.is_empty() { - return RString::new_utf8("/"); - } - let pos = match find_last_non_sep_pos(path.as_bytes()) { - Some(pos) => pos, - None => return RString::new_utf8("/"), - }; - if pos != path.len() - 1 { - return RString::new_utf8(&path[..pos + 1]); - } +fn entries(path: &PathString, error_path: &AnyObject, mut wrap: F) -> RubyResult + where F: FnMut(RString) -> RubyResult { + let files = read_dir(path, error_path)?; + let filesystem = EncodingId::filesystem(); + let mut array = Array::with_capacity(files.size_hint().0 + 2); + array.push(wrap(filesystem.new_string(b"."))?); + array.push(wrap(filesystem.new_string(b".."))?); + for file in files { + let file = file.map_err(|e| system_call_error(&e, error_path, "dir_read"))?; + array.push(wrap(filesystem.new_string(&os_bytes(&file.file_name())))?); } - pth.unwrap() + Ok(array.into()) } -// pub fn pn_descend(){} - -pub fn pn_is_directory(pth: MaybeString) -> Boolean { - Boolean::new(Path::new(to_str(&pth)).is_dir()) +pub fn pn_extname(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(extname::extname(path.bytes())).into()) } -pub fn pn_dirname(pth: MaybeString) -> RString { - RString::new_utf8(dirname::dirname(to_str(&pth))) +pub fn pn_has_trailing_separator(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(Boolean::new(has_trailing_separator(path.bytes())).into()) } -// pub fn pn_each_child(){} - -// pub fn pn_each_filename(pth: MaybeString) -> NilClass { -// NilClass::new() -// } - -pub fn pn_entries(pth: MaybeString) -> Result { - let path = to_str(&pth); - if let Ok(files) = fs::read_dir(path) { - let mut arr = Array::with_capacity(files.size_hint().1.unwrap_or(0) + 2); - - arr.push(RString::new_utf8(".")); - arr.push(RString::new_utf8("..")); +pub fn pn_join(arguments: &[AnyObject]) -> RubyResult { + if arguments.is_empty() { + return Err(argument_error("wrong number of arguments (given 0, expected 1+)")); + } + let pathname = pathname_class()?; + + // Copy every path out of Ruby before calling more Ruby code: the strings + // returned by `to_path` or `to_s` aren't referenced from anywhere the + // garbage collector looks once we only hold their bytes. + let mut paths: Vec> = Vec::with_capacity(arguments.len()); + let mut encoding: Option = None; + for argument in arguments { + let string = path_like_to_string(argument, &pathname)?; + let path = PathString::new(&string)?; + encoding = Some(match encoding { + None => EncodingOf::new(&path), + Some(previous) => previous.merge(&path)?, + }); + paths.push(path.bytes().to_vec()); + } - for file in files { - arr.push(RString::new_utf8(file.unwrap().file_name().to_str().unwrap())); + let mut parts = paths.iter().rev(); + let mut result: Cow<[u8]> = match parts.next() { + Some(last) => Cow::Borrowed(last), + None => Cow::Borrowed(b""), + }; + for part in parts { + if result.first() == Some(&SEP) { + break; } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) + result = Cow::Owned(plus::plus_paths(part, &result).into_owned()); } + let encoding = match encoding { + Some(encoding) => encoding.encoding, + None => EncodingId::us_ascii(), + }; + new_pathname(&pathname, encoding.new_string(&result)) } -pub fn pn_entries_compat(pth: MaybeString) -> Result { - let path = to_str(&pth); - if let Ok(files) = fs::read_dir(path) { - let mut arr = Array::with_capacity(files.size_hint().1.unwrap_or(0) + 2); +pub fn pn_plus(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument1 = PathArgument::new(arguments, 0)?; + let path1 = argument1.path()?; + let argument2 = PathArgument::new(arguments, 1)?; + let path2 = argument2.path()?; + let encoding = EncodingOf::new(&path1).merge(&path2)?.encoding; + Ok(encoding.new_string(&plus::plus_paths(path1.bytes(), path2.bytes())).into()) +} - arr.push(Pathname::new(".")); - arr.push(Pathname::new("..")); +pub fn pn_is_relative(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + if argument.is_missing() { + return Ok(Boolean::new(false).into()); + } + let path = argument.path()?; + Ok(Boolean::new(path.bytes().first() != Some(&SEP)).into()) +} - for file in files { - arr.push(Pathname::new(file.unwrap().file_name().to_str().unwrap())); +pub fn pn_relative_path_from(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let pathname = pathname_class()?; + let argument = PathArgument::new(arguments, 0)?; + let dest = argument.path()?; + let base = match arguments.get(1) { + Some(base_directory) => { + let base_string = path_like_to_string(base_directory, &pathname)?; + // Copied right away, see `pn_join`. + PathString::new(&base_string)?.into_owned() } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) + None => PathString::empty(), + }; + let encoding = EncodingOf::new(&dest).merge(&base)?.encoding; + let inspect = |bytes: &[u8]| crate::ruby::inspect(&encoding.new_string(bytes)); + + match relative_path_from::relative_path_from(dest.bytes(), base.bytes()) { + Ok(path) => new_pathname(&pathname, encoding.new_string(&path)), + Err(RelativePathError::DifferentPrefix(dest_prefix, base_directory)) => { + let message = format!("different prefix: {} and {}", inspect(&dest_prefix)?, inspect(&base_directory)?); + Err(argument_error(&message)) + }, + Err(RelativePathError::BaseDirectoryHasDotDot(base_directory)) => { + Err(argument_error(&format!("base_directory has ..: {}", inspect(&base_directory)?))) + }, } } -pub fn pn_extname(pth: MaybeString) -> RString { - RString::new_utf8(extname::extname(to_str(&pth))) +// The path to name in a `SystemCallError` +fn error_path(argument: &PathArgument, default: &str) -> AnyObject { + match argument.string() { + Some(string) => string.to_any_object(), + None => RString::new_utf8(default).into(), + } } -// pub fn pn_find(pth: MaybeString, ignore_error: Boolean){} - -pub fn pn_has_trailing_separator(pth: MaybeString) -> Boolean { - let v = to_str(&pth); - match chop_basename::chop_basename(v) { - Some((a,b)) => Boolean::new(a.len() + b.len() < v.len()), - _ => Boolean::new(false) - } +fn read_dir(path: &PathString, error_path: &AnyObject) -> Result { + let os_path = os_path(path.bytes()).map_err(|e| system_call_error(&e, error_path, "dir_initialize"))?; + fs::read_dir(os_path).map_err(|e| system_call_error(&e, error_path, "dir_initialize")) } -pub fn pn_join(args: MaybeArray) -> AnyObject { - let paths = args.unwrap().into_iter().map(|arg| anyobject_to_string(arg).unwrap()).collect::>(); - let mut paths_iter = paths.iter().rev(); - let mut result = Cow::Borrowed(paths_iter.next().unwrap().as_str()); - for part in paths_iter { - result = plus::plus_paths(&part, result.as_ref()); - if result.as_bytes().first() == Some(&SEP) { - break; +// `SystemCallError.new(path, errno, func)` gives the same `Errno::*` error +// and message as Ruby's `Dir` methods, e.g. +// "No such file or directory @ dir_initialize - path". +fn system_call_error(err: &io::Error, path: &AnyObject, func: &str) -> AnyException { + #[cfg(unix)] + { + if let Some(errno) = err.raw_os_error() { + let arguments = [path.clone(), rutie::Integer::new(errno.into()).into(), RString::new_utf8(func).into()]; + if let Ok(exception) = Class::system_call_error().protect_send("new", &arguments) { + if let Ok(exception) = exception.try_convert_to::() { + return exception; + } + } } } - Pathname::new(&result).to_any_object() + #[cfg(not(unix))] + let _ = func; + let path = crate::ruby::inspect(path).unwrap_or_default(); + AnyException::from_io_error(err, &path) } -// pub fn pn_mkpath(pth: MaybeString) -> NilClass { -// NilClass::new() -// } - -// pub fn pn_is_mountpoint(pth: MaybeString){} - -// pub fn pn_parent(pth: MaybeString){} - -pub fn pn_plus(pth1: MaybeString, pth2: MaybeString) -> RString { - RString::new_utf8(&plus::plus_paths(to_str(&pth1), to_str(&pth2))) +#[cfg(unix)] +fn os_path(bytes: &[u8]) -> io::Result<&Path> { + use std::os::unix::ffi::OsStrExt; + Ok(Path::new(OsStr::from_bytes(bytes))) } -// pub fn pn_prepend_prefix(prefix: MaybeString, relpath: MaybeString){} - -pub fn pn_is_relative(pth: MaybeString) -> Boolean { - let path = match &pth { - &Ok(ref rutie_string) => rutie_string.to_str(), - &Err(_) => return Boolean::new(false), - }; - Boolean::new(path.as_bytes().get(0) != Some(&SEP)) +#[cfg(not(unix))] +fn os_path(bytes: &[u8]) -> io::Result<&Path> { + std::str::from_utf8(bytes).map(Path::new). + map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path name is not valid UTF-8")) } -// pub fn pn_root(pth: MaybeString){} - -// pub fn pn_split_names(pth: MaybeString){} +#[cfg(unix)] +fn os_bytes(string: &OsStr) -> Cow<'_, [u8]> { + use std::os::unix::ffi::OsStrExt; + Cow::Borrowed(string.as_bytes()) +} -pub fn pn_relative_path_from(itself: MaybeString, base_directory: MaybeString) -> Result { - relative_path_from::relative_path_from(itself, base_directory) +#[cfg(not(unix))] +fn os_bytes(string: &OsStr) -> Cow<'_, [u8]> { + match string.to_string_lossy() { + Cow::Borrowed(string) => Cow::Borrowed(string.as_bytes()), + Cow::Owned(string) => Cow::Owned(string.into_bytes()), + } } -// pub fn pn_rmtree(pth: MaybeString) -> NilClass { -// NilClass::new() -// } -// +fn implicit_conversion_error(argument: Option<&AnyObject>) -> AnyException { + let name = match argument { + None => "nil".to_string(), + Some(argument) if argument.is_nil() => "nil".to_string(), + Some(argument) if argument.value().is_true() => "true".to_string(), + Some(argument) if argument.value().is_false() => "false".to_string(), + Some(argument) => argument.protect_send("class", &[]).ok(). + and_then(|class| crate::ruby::inspect(&class).ok()).unwrap_or_default(), + }; + error(&Class::type_error(), &format!("no implicit conversion of {} into String", name)) +} diff --git a/src/pathname_sys.rs b/src/pathname_sys.rs deleted file mode 100644 index 376618f..0000000 --- a/src/pathname_sys.rs +++ /dev/null @@ -1,24 +0,0 @@ -use rutie::{AnyObject, Array}; -use rutie::types::{Argc, Value}; -use rutie::util::str_to_cstring; -use rutie::rubysys::class; -use ::pathname; -use std::mem; - -// rutie doesn't support splat arguments yet natively -pub extern fn pub_join(argc: Argc, argv: *const AnyObject, _: AnyObject) -> AnyObject { - let args = Value::from(0); - - unsafe { - let p_argv: *const Value = mem::transmute(argv); - - class::rb_scan_args( - argc, - p_argv, - str_to_cstring("*").as_ptr(), - &args - ) - }; - - pathname::pn_join(Ok(Array::from(args))) -} diff --git a/src/plus.rs b/src/plus.rs index 227a650..93bfd01 100644 --- a/src/plus.rs +++ b/src/plus.rs @@ -1,32 +1,25 @@ use std::borrow::Cow; -use std::str; -use std::path::MAIN_SEPARATOR; -use chop_basename::chop_basename; -use path_parsing::SEP; +use crate::chop_basename::chop_basename; +use crate::path_parsing::SEP; -pub fn plus_paths<'a>(path1: &'a str, path2: &str) -> Cow<'a, str> { +pub fn plus_paths<'a>(path1: &'a [u8], path2: &[u8]) -> Cow<'a, [u8]> { let mut prefix2 = path2; let mut index_list2: Vec = vec![]; - let mut basename_list2: Vec<&str> = vec![]; - loop { - match chop_basename(prefix2) { - None => { break; } - Some((pfx2, basename2)) => { - prefix2 = pfx2; - index_list2.push(pfx2.len()); - basename_list2.push(basename2); - } - } + let mut basename_list2: Vec<&[u8]> = vec![]; + while let Some((pfx2, basename2)) = chop_basename(prefix2) { + prefix2 = pfx2; + index_list2.push(pfx2.len()); + basename_list2.push(basename2); } if !prefix2.is_empty() { - return path2.to_string().into(); + return path2.to_vec().into(); }; - let result_prefix: Cow; + let result_prefix: Cow<'a, [u8]>; let mut prefix1 = path1; loop { - let mut new_len = basename_list2.len() - count_trailing(".", &basename_list2); + let mut new_len = basename_list2.len() - count_trailing(b".", &basename_list2); index_list2.truncate(new_len); basename_list2.truncate(new_len); match chop_basename(prefix1) { @@ -36,8 +29,8 @@ pub fn plus_paths<'a>(path1: &'a str, path2: &str) -> Cow<'a, str> { } Some((pfx1, basename1)) => { prefix1 = pfx1; - if basename1 == "." { continue; }; - if basename1 == ".." || basename_list2.last() != Some(&"..") { + if basename1 == b"." { continue; }; + if basename1 == b".." || basename_list2.last() != Some(&&b".."[..]) { result_prefix = [prefix1, basename1].concat().into(); break; } @@ -50,53 +43,56 @@ pub fn plus_paths<'a>(path1: &'a str, path2: &str) -> Cow<'a, str> { } } - if !result_prefix.is_empty() && result_prefix.as_bytes().iter().cloned().all(|b| b == SEP) { - let new_len = basename_list2.len() - count_trailing("..", &basename_list2); + if !result_prefix.is_empty() && result_prefix.iter().all(|&b| b == SEP) { + let new_len = basename_list2.len() - count_trailing(b"..", &basename_list2); index_list2.truncate(new_len); basename_list2.truncate(new_len); } - if let Some(last_index2) = index_list2.last() { - let suffix = &path2[*last_index2..]; - match (result_prefix.as_bytes().last(), suffix.as_bytes().first()) { - (Some(&SEP), Some(&SEP)) => [&result_prefix, &suffix[1..]].concat().into(), - (Some(&SEP), Some(_)) | (Some(_), Some(&SEP)) => [&result_prefix, suffix].concat().into(), - (None, Some(_)) => suffix.to_string().into(), - _ => format!("{}{}{}", result_prefix.as_ref(), MAIN_SEPARATOR, suffix).into(), + if let Some(&last_index2) = index_list2.last() { + let suffix = &path2[last_index2..]; + match (result_prefix.last(), suffix.first()) { + (Some(&SEP), Some(&SEP)) => [&result_prefix[..], &suffix[1..]].concat().into(), + (Some(&SEP), Some(_)) | (Some(_), Some(&SEP)) => [&result_prefix[..], suffix].concat().into(), + (None, Some(_)) => suffix.to_vec().into(), + _ => [&result_prefix[..], &[SEP], suffix].concat().into(), } + } else if result_prefix.is_empty() { + Cow::Borrowed(b".") } else { - if result_prefix.is_empty() { - ".".into() - } else { - result_prefix - } + result_prefix } } #[inline(always)] -fn count_trailing(x: &str, xs: &Vec<&str>) -> usize { - xs.iter().rev().take_while(|&c| c == &x).count() +fn count_trailing(x: &[u8], xs: &[&[u8]]) -> usize { + xs.iter().rev().take_while(|&&c| c == x).count() +} + +#[cfg(test)] +fn plus_str(path1: &str, path2: &str) -> String { + String::from_utf8(plus_paths(path1.as_bytes(), path2.as_bytes()).into_owned()).unwrap() } #[test] fn it_will_plus_same_as_ruby() { - assert_eq!("/" , plus_paths("/" , "/")); - assert_eq!("a/b" , plus_paths("a" , "b")); - assert_eq!("a" , plus_paths("a" , ".")); - assert_eq!("b" , plus_paths("." , "b")); - assert_eq!("." , plus_paths("." , ".")); - assert_eq!("/b" , plus_paths("a" , "/b")); + assert_eq!("/" , plus_str("/" , "/")); + assert_eq!("a/b" , plus_str("a" , "b")); + assert_eq!("a" , plus_str("a" , ".")); + assert_eq!("b" , plus_str("." , "b")); + assert_eq!("." , plus_str("." , ".")); + assert_eq!("/b" , plus_str("a" , "/b")); - assert_eq!("/" , plus_paths("/" , "..")); - assert_eq!("////" , plus_paths("////", "")); - assert_eq!("." , plus_paths("a" , "..")); - assert_eq!("a" , plus_paths("a/b", "..")); - assert_eq!("../.." , plus_paths(".." , "..")); - assert_eq!("/c" , plus_paths("/" , "../c")); - assert_eq!("c" , plus_paths("a" , "../c")); - assert_eq!("a/c" , plus_paths("a/b", "../c")); - assert_eq!("../../c", plus_paths(".." , "../c")); + assert_eq!("/" , plus_str("/" , "..")); + assert_eq!("////" , plus_str("////", "")); + assert_eq!("." , plus_str("a" , "..")); + assert_eq!("a" , plus_str("a/b", "..")); + assert_eq!("../.." , plus_str(".." , "..")); + assert_eq!("/c" , plus_str("/" , "../c")); + assert_eq!("c" , plus_str("a" , "../c")); + assert_eq!("a/c" , plus_str("a/b", "../c")); + assert_eq!("../../c", plus_str(".." , "../c")); - assert_eq!("a//b/d//e", plus_paths("a//b/c", "../d//e")); + assert_eq!("a//b/d//e", plus_str("a//b/c", "../d//e")); - assert_eq!("//foo/var/bar", plus_paths("//foo/var", "bar")); + assert_eq!("//foo/var/bar", plus_str("//foo/var", "bar")); } diff --git a/src/prepend_prefix.rs b/src/prepend_prefix.rs index fadb54b..ae8b536 100644 --- a/src/prepend_prefix.rs +++ b/src/prepend_prefix.rs @@ -1,19 +1,18 @@ use std::borrow::Cow; -use dirname::dirname; -use path_parsing::{SEP, contains_sep}; -use std::path::MAIN_SEPARATOR; +use crate::dirname::dirname; +use crate::path_parsing::{SEP, contains_sep}; -pub fn prepend_prefix<'a>(prefix: &'a str, relpath: &str) -> Cow<'a, str> { +pub fn prepend_prefix<'a>(prefix: &'a [u8], relpath: &[u8]) -> Cow<'a, [u8]> { if relpath.is_empty() { dirname(prefix).into() - } else if contains_sep(prefix.as_bytes()) { + } else if contains_sep(prefix) { let prefix_dirname = dirname(prefix); - match prefix_dirname.as_bytes().last() { - None => relpath.to_string().into(), - Some(&SEP) => format!("{}{}", prefix_dirname, relpath).into(), - _ => format!("{}{}{}", prefix_dirname, MAIN_SEPARATOR, relpath).into() + match prefix_dirname.last() { + None => relpath.to_vec().into(), + Some(&SEP) => [prefix_dirname, relpath].concat().into(), + _ => [prefix_dirname, &[SEP], relpath].concat().into(), } } else { - format!("{}{}", prefix, relpath).into() + [prefix, relpath].concat().into() } } diff --git a/src/relative_path_from.rs b/src/relative_path_from.rs index 5b11ab9..b3a619b 100644 --- a/src/relative_path_from.rs +++ b/src/relative_path_from.rs @@ -1,70 +1,83 @@ -use std::iter; -use helpers::{is_same_path, to_str}; -use path_parsing::SEP_STR; -use cleanpath_aggressive::cleanpath_aggressive; -use chop_basename::chop_basename; -use pathname::Pathname; -use rutie; -use rutie::{Exception as Exc, AnyException as Exception}; +use crate::helpers::is_same_path; +use crate::path_parsing::SEP_BYTES; +use crate::cleanpath_aggressive::cleanpath_aggressive; +use crate::chop_basename::chop_basename; -type MaybeString = Result; +#[derive(Debug, PartialEq)] +pub enum RelativePathError { + // Holds the destination prefix and the cleaned base directory. + DifferentPrefix(Vec, Vec), + // Holds the cleaned base directory. + BaseDirectoryHasDotDot(Vec), +} -pub fn relative_path_from(itself: MaybeString, base_directory: MaybeString) -> Result { - let dest_directory = cleanpath_aggressive(to_str(&itself)); - let base_directory = cleanpath_aggressive(to_str(&base_directory)); +pub fn relative_path_from(dest: &[u8], base: &[u8]) -> Result, RelativePathError> { + let dest_directory = cleanpath_aggressive(dest); + let base_directory = cleanpath_aggressive(base); - let (dest_prefix, mut dest_names) = to_names(dest_directory.as_ref()); - let (base_prefix, mut base_names) = to_names(base_directory.as_ref()); + let (dest_prefix, mut dest_names) = to_names(&dest_directory); + let (base_prefix, mut base_names) = to_names(&base_directory); - if !is_same_path(&dest_prefix, &base_prefix) { - return Err( - Exception::new( - "ArgumentError", - Some(&format!("different prefix: {} and {}", dest_prefix, base_prefix)), - ) - ); + if !is_same_path(dest_prefix, base_prefix) { + return Err(RelativePathError::DifferentPrefix(dest_prefix.to_vec(), base_directory.to_vec())); } - // Remove shared tail + // Remove the shared leading names (stored last, as the names are collected in reverse) { let num_same = dest_names.iter().rev().zip(base_names.iter().rev()). - take_while(|&(dest, base)| dest == base).count(); - let num_dest_names = dest_names.len(); - dest_names.truncate(num_dest_names - num_same); - let num_base_names = base_names.len(); - base_names.truncate(num_base_names - num_same); + take_while(|&(dest, base)| is_same_path(dest, base)).count(); + dest_names.truncate(dest_names.len() - num_same); + base_names.truncate(base_names.len() - num_same); }; - if base_names.contains(&"..") { - return Err( - Exception::new( - "ArgumentError", - Some(&format!("base_directory has ..: {}", base_directory)), - ) - ); + if base_names.contains(&&b".."[..]) { + return Err(RelativePathError::BaseDirectoryHasDotDot(base_directory.to_vec())); } if base_names.is_empty() && dest_names.is_empty() { - Ok(Pathname::new(".")) + Ok(b".".to_vec()) } else { - Ok(Pathname::new(&iter::repeat("..").take(base_names.len()).chain(dest_names.into_iter().rev()). - collect::>().join(&SEP_STR))) + let names: Vec<&[u8]> = std::iter::repeat(&b".."[..]).take(base_names.len()). + chain(dest_names.into_iter().rev()).collect(); + Ok(names.join(SEP_BYTES)) } } #[inline(always)] -fn to_names(path: &str) -> (&str, Vec<&str>) { - let mut result: Vec<&str> = vec![]; +fn to_names(path: &[u8]) -> (&[u8], Vec<&[u8]>) { + let mut result: Vec<&[u8]> = vec![]; let mut prefix = path; - loop { - match chop_basename(&prefix) { - Some((ref dest, ref basename)) => { - prefix = dest; - if basename != &"." { - result.push(basename); - } - } - None => return (prefix, result), + while let Some((dest, basename)) = chop_basename(prefix) { + prefix = dest; + if basename != b"." { + result.push(basename); } } + (prefix, result) +} + +#[cfg(test)] +fn relative_str(dest: &str, base: &str) -> Option { + relative_path_from(dest.as_bytes(), base.as_bytes()).ok().map(|path| String::from_utf8(path).unwrap()) +} + +#[test] +fn it_finds_relative_paths() { + assert_eq!(relative_str("a", "b").unwrap(), "../a"); + assert_eq!(relative_str("/a/b/c/d", "/a/b").unwrap(), "c/d"); + assert_eq!(relative_str("/a/b", "/a/b/c/d").unwrap(), "../.."); + assert_eq!(relative_str(".", ".").unwrap(), "."); + assert_eq!(relative_str("a/b/c", "a/d").unwrap(), "../b/c"); +} + +#[test] +fn it_rejects_incompatible_paths() { + assert_eq!( + relative_path_from(b"/", b"."), + Err(RelativePathError::DifferentPrefix(b"/".to_vec(), b".".to_vec())) + ); + assert_eq!( + relative_path_from(b"a", b".."), + Err(RelativePathError::BaseDirectoryHasDotDot(b"..".to_vec())) + ); } diff --git a/src/ruby.rs b/src/ruby.rs new file mode 100644 index 0000000..a80536d --- /dev/null +++ b/src/ruby.rs @@ -0,0 +1,444 @@ +// Glue between Ruby and the path functions, written so that nothing Ruby +// passes in can crash the interpreter: +// +// * Panics never unwind into Ruby; they are caught and raised as `RuntimeError`. +// * Ruby exceptions are only raised once every Rust value owning memory has +// been dropped, since raising `longjmp`s past Rust frames without running +// their destructors. +// * Ruby code (`to_path`, `to_s`, ...) is only called through `protect_send`, +// so an exception it raises comes back as an `Err` instead of jumping over +// Rust frames. +// * Strings are read as bytes in their own encoding, never assumed to be UTF-8. +use std::any::Any; +use std::borrow::Cow; +use std::sync::atomic::{AtomicI32, AtomicU8, Ordering}; +use std::thread; + +use rutie::rubysys; +use rutie::types::{c_char, c_long, Argc, EncodingIndex, Value, ValueType}; +use rutie::{AnyException, AnyObject, Class, Encoding, NilClass, Object, RString, Symbol, VM}; + +pub type RubyResult = Result; + +// Defines Ruby methods taking any number of arguments (arity -1). The body +// sees the arguments as `&[AnyObject]` and returns a `RubyResult`; an `Err` +// is raised in Ruby. +macro_rules! ruby_methods { + ($($(#[$attribute:meta])* fn $name:ident($arguments:ident) $body:block)*) => {$( + rutie::rutie_callback! { + $(#[$attribute])* + pub fn $name(argc: rutie::types::Argc, argv: *const rutie::AnyObject, _itself: rutie::AnyObject) -> rutie::AnyObject { + // Safety: Ruby passes `argc` arguments in `argv`, alive for the whole call. + let $arguments = unsafe { $crate::ruby::arguments(argc, argv) }; + $crate::ruby::finish(std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> $crate::ruby::RubyResult { $body } + ))) + } + } + )*}; +} + +/// Borrows the arguments of a method call. +/// +/// # Safety +/// +/// `argv` must point to `argc` Ruby values that stay alive for `'a`, as the +/// arguments of a method call do while the method runs. +pub unsafe fn arguments<'a>(argc: Argc, argv: *const AnyObject) -> &'a [AnyObject] { + if argc <= 0 || argv.is_null() { + &[] + } else { + // Safety: guaranteed by the caller. + unsafe { std::slice::from_raw_parts(argv, argc as usize) } + } +} + +/// Returns the method's result to Ruby, or raises its error. +/// +/// Raising does not return, so it happens here, after the method body and +/// everything it allocated have been dropped. +pub fn finish(result: thread::Result) -> AnyObject { + let exception = match result { + Ok(Ok(value)) => return value, + Ok(Err(exception)) => exception, + Err(payload) => panic_exception(payload), + }; + VM::raise_ex(exception); + NilClass::new().into() +} + +fn panic_exception(payload: Box) -> AnyException { + let message = if let Some(message) = payload.downcast_ref::<&str>() { + message.to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown error".to_string() + }; + error(&Class::runtime_error(), &format!("faster_path internal error: {}", message)) +} + +pub fn error(class: &Class, message: &str) -> AnyException { + AnyException::from_class(class, message) +} + +pub fn argument_error(message: &str) -> AnyException { + error(&Class::argument_error(), message) +} + +/// Raises `ArgumentError` unless there are at most `max` arguments. +pub fn check_max_arguments(arguments: &[AnyObject], max: usize) -> Result<(), AnyException> { + if arguments.len() > max { + let expected = if max == 0 { "0".to_string() } else { format!("0..{}", max) }; + Err(argument_error(&format!( + "wrong number of arguments (given {}, expected {})", + arguments.len(), + expected + ))) + } else { + Ok(()) + } +} + +/// Truthiness of an optional argument. +pub fn truthy_argument(arguments: &[AnyObject], index: usize, default: bool) -> bool { + match arguments.get(index) { + Some(argument) => !(argument.is_nil() || argument.value().is_false()), + None => default, + } +} + +/// A path argument. +pub enum PathArgument { + /// Missing, `nil`, or neither a `String` nor something with `to_path`. + /// The methods have always treated these as an empty path. + Missing, + /// A `String` argument. + String(RString), + /// The `String` an argument's `to_path` returned. + Converted(RString), +} + +impl PathArgument { + /// Reads the argument at `index` like Ruby's `File` methods do: a `String`, + /// or an object with `to_path`. + pub fn new(arguments: &[AnyObject], index: usize) -> Result { + let argument = match arguments.get(index) { + Some(argument) => argument, + None => return Ok(PathArgument::Missing), + }; + if let Ok(string) = argument.try_convert_to::() { + return Ok(PathArgument::String(string)); + } + if argument.is_nil() { + return Ok(PathArgument::Missing); + } + let responds_to_path = argument.protect_send("respond_to?", &[Symbol::new("to_path").into()])?; + if !responds_to_path.value().is_true() { + return Ok(PathArgument::Missing); + } + let path = argument.protect_send("to_path", &[])?; + Ok(PathArgument::Converted(expect_string(path, argument, "to_path")?)) + } + + pub fn is_missing(&self) -> bool { + matches!(self, PathArgument::Missing) + } + + /// The path, or `default` if the argument is missing. + pub fn path_or(&self, default: &'static [u8]) -> Result, AnyException> { + match self { + PathArgument::Missing => Ok(PathString::literal(default)), + PathArgument::String(string) => PathString::new(string), + // Nothing but this value refers to the string `to_path` returned, and + // the garbage collector may not see it, so keep a copy. + PathArgument::Converted(string) => PathString::new(string).map(PathString::into_owned), + } + } + + pub fn path(&self) -> Result, AnyException> { + self.path_or(b"") + } + + /// The `String`, for error messages or to return unchanged. + pub fn string(&self) -> Option<&RString> { + match self { + PathArgument::Missing => None, + PathArgument::String(string) | PathArgument::Converted(string) => Some(string), + } + } +} + +/// A path read from a Ruby `String`: its bytes and its encoding. +pub struct PathString<'a> { + bytes: Cow<'a, [u8]>, + pub encoding: EncodingId, +} + +impl<'a> PathString<'a> { + /// An empty path, used where a `String` argument is missing. + pub fn empty() -> Self { + PathString::literal(b"") + } + + pub fn literal(bytes: &'static [u8]) -> Self { + PathString { bytes: Cow::Borrowed(bytes), encoding: EncodingId::us_ascii() } + } + + /// Reads `string`, which must be kept alive (and unmodified) while the + /// result is in use; a method argument is. + /// + /// Like Ruby's own path methods this rejects strings that are not + /// ASCII-compatible (such as UTF-16), since those can't be split on `/`, + /// and strings containing a null byte. + pub fn new(string: &'a RString) -> Result { + let encoding = EncodingId::of(string); + if !encoding.is_ascii_compatible() { + return Err(error( + &Class::encoding_compatibility_error(), + &format!("path name must be ASCII-compatible ({}): {}", encoding.name(), inspect(string)?), + )); + } + // This returns the bytes of a `String`; `RString` is always one. + let bytes = string.to_bytes_unchecked(); + if bytes.contains(&0) { + return Err(argument_error("path name contains null byte")); + } + Ok(PathString { bytes: Cow::Borrowed(bytes), encoding }) + } + + pub fn into_owned(self) -> PathString<'static> { + PathString { bytes: Cow::Owned(self.bytes.into_owned()), encoding: self.encoding } + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// A new Ruby string with `bytes` in this path's encoding. + pub fn to_ruby(&self, bytes: &[u8]) -> RString { + self.encoding.new_string(bytes) + } + + /// Whether `pos` starts a character of `bytes`, which must start at a + /// character of this path's encoding. + pub fn is_char_boundary(&self, bytes: &[u8], pos: usize) -> bool { + if pos == 0 || pos >= bytes.len() { + return true; + } + if self.encoding == EncodingId::utf8() { + // Not a continuation byte + return (bytes[pos] & 0xC0) != 0x80; + } + if self.encoding == EncodingId::us_ascii() || self.encoding == EncodingId::ascii_8bit() { + return true; + } + // Other encodings: walk the characters from the start like Ruby does. + // Safety: the encoding comes from a string or `Encoding`, so its index + // is valid, and every pointer passed to `rb_enc_mbclen` is within + // `bytes`, with `end` one past its last byte. + let encoding = unsafe { rubysys::encoding::rb_enc_from_index(self.encoding.0) }; + let end = bytes.as_ptr_range().end as *const c_char; + let mut index = 0; + while index < pos { + let start = bytes[index..].as_ptr() as *const c_char; + let length = unsafe { rubysys::encoding::rb_enc_mbclen(start, end, encoding) }; + index += length.max(1) as usize; + } + index == pos + } +} + +/// An encoding, by its index in Ruby's table of encodings. +/// +/// `Encoding` objects compare with `Encoding#==` and answer +/// `Encoding#ascii_compatible?` through Ruby method calls, which cost more +/// than most path operations; this compares indexes, and remembers whether +/// an encoding is ASCII-compatible (which never changes). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct EncodingId(EncodingIndex); + +static UTF8_INDEX: AtomicI32 = AtomicI32::new(-1); +static US_ASCII_INDEX: AtomicI32 = AtomicI32::new(-1); +static ASCII_8BIT_INDEX: AtomicI32 = AtomicI32::new(-1); + +const UNKNOWN: u8 = 0; +const ASCII_COMPATIBLE: u8 = 1; +const NOT_ASCII_COMPATIBLE: u8 = 2; +#[allow(clippy::declare_interior_mutable_const)] +const UNKNOWN_SLOT: AtomicU8 = AtomicU8::new(UNKNOWN); +static ASCII_COMPATIBILITY: [AtomicU8; 128] = [UNKNOWN_SLOT; 128]; + +impl EncodingId { + /// The encoding of a `String`. + pub fn of(string: &RString) -> Self { + // Safety: `string` is a `String`. + EncodingId(unsafe { rubysys::encoding::rb_enc_get_index(string.value()) }) + } + + pub fn from_encoding(encoding: &Encoding) -> Self { + EncodingId(encoding.index()) + } + + pub fn filesystem() -> Self { + EncodingId::from_encoding(&Encoding::filesystem()) + } + + pub fn utf8() -> Self { + EncodingId::known(&UTF8_INDEX, Encoding::utf8) + } + + pub fn us_ascii() -> Self { + EncodingId::known(&US_ASCII_INDEX, Encoding::us_ascii) + } + + pub fn ascii_8bit() -> Self { + EncodingId::known(&ASCII_8BIT_INDEX, Encoding::ascii_8bit) + } + + fn known(cache: &AtomicI32, encoding: fn() -> Encoding) -> Self { + let index = cache.load(Ordering::Relaxed); + if index >= 0 { + return EncodingId(index); + } + let index = encoding().index(); + cache.store(index, Ordering::Relaxed); + EncodingId(index) + } + + /// The `Encoding` object. + pub fn to_encoding(self) -> Encoding { + // Safety: the index is valid (see `is_char_boundary`). + Encoding::from(unsafe { + rubysys::encoding::rb_enc_from_encoding(rubysys::encoding::rb_enc_from_index(self.0)) + }) + } + + pub fn name(self) -> String { + self.to_encoding().name() + } + + pub fn is_ascii_compatible(self) -> bool { + let slot = usize::try_from(self.0).ok().and_then(|index| ASCII_COMPATIBILITY.get(index)); + match slot.map(|slot| slot.load(Ordering::Relaxed)) { + Some(ASCII_COMPATIBLE) => true, + Some(NOT_ASCII_COMPATIBLE) => false, + _ => { + let compatible = self.to_encoding().is_ascii_compatible(); + if let Some(slot) = slot { + slot.store(if compatible { ASCII_COMPATIBLE } else { NOT_ASCII_COMPATIBLE }, Ordering::Relaxed); + } + compatible + } + } + } + + /// A new `String` of `bytes` in this encoding. + pub fn new_string(self, bytes: &[u8]) -> RString { + // Safety: `bytes` is valid for its length, which Ruby copies, and the + // index is valid (see `is_char_boundary`). + RString::from(unsafe { + rubysys::string::rb_enc_str_new( + bytes.as_ptr() as *const c_char, + bytes.len() as c_long, + rubysys::encoding::rb_enc_from_index(self.0), + ) + }) + } +} + +/// The encoding of a string built from parts of other strings, and whether +/// all of those were ASCII only. +pub struct EncodingOf { + pub encoding: EncodingId, + ascii_only: bool, +} + +impl EncodingOf { + pub fn new(path: &PathString) -> Self { + EncodingOf::of_bytes(path.bytes(), path.encoding) + } + + pub fn of_bytes(bytes: &[u8], encoding: EncodingId) -> Self { + EncodingOf { encoding, ascii_only: bytes.is_ascii() } + } + + pub fn merge(self, path: &PathString) -> Result { + self.merge_with(EncodingOf::new(path)) + } + + /// Ruby's rules for the encoding of two concatenated strings. + pub fn merge_with(self, other: EncodingOf) -> Result { + if self.encoding == other.encoding || other.ascii_only { + Ok(EncodingOf { ascii_only: self.ascii_only && other.ascii_only, ..self }) + } else if self.ascii_only { + Ok(other) + } else { + Err(error( + &Class::encoding_compatibility_error(), + &format!("incompatible character encodings: {} and {}", self.encoding.name(), other.encoding.name()), + )) + } + } +} + +/// `object.inspect` +pub fn inspect(object: &T) -> Result { + let inspected = object.protect_send("inspect", &[])?; + Ok(match inspected.try_convert_to::() { + Ok(string) => String::from_utf8_lossy(string.to_bytes_unchecked()).into_owned(), + Err(_) => String::new(), + }) +} + +/// Converts a path-like object to a `String` the way `FasterPath.join` and +/// `FasterPath.relative_path_from` always have: `String`s as they are, +/// `Pathname`s by their path, then `to_path`, then `to_s`. +pub fn path_like_to_string(object: &AnyObject, pathname_class: &Class) -> Result { + if object.ty() == ValueType::RString { + return object.try_convert_to::(); + } + if object.is_kind_of(pathname_class) { + let path = object.protect_send("to_s", &[])?; + return expect_string(path, object, "to_s"); + } + let responds_to_path = object.protect_send("respond_to?", &[Symbol::new("to_path").into()])?; + let method = if responds_to_path.value().is_true() { "to_path" } else { "to_s" }; + let path = object.protect_send(method, &[])?; + expect_string(path, object, method) +} + +fn expect_string(result: AnyObject, object: &AnyObject, method: &str) -> Result { + result.try_convert_to::().map_err(|_| { + let class_name = object.protect_send("class", &[]).ok(). + and_then(|class| inspect(&class).ok()).unwrap_or_default(); + error( + &Class::type_error(), + &format!("can't convert {} to String ({}#{} gives {})", class_name, class_name, method, result_class(&result)), + ) + }) +} + +fn result_class(result: &AnyObject) -> String { + result.protect_send("class", &[]).ok().and_then(|class| inspect(&class).ok()).unwrap_or_default() +} + +pub fn pathname_class() -> Result { + Class::from_path("Pathname") +} + +extern "C" { + fn rb_obj_alloc(klass: Value) -> Value; +} + +/// A `Pathname` of `path`, a new `String` without null bytes. +/// +/// This sets `@path` like `Pathname#initialize` does instead of calling +/// `Pathname.new`, which is much faster and runs no Ruby code. +pub fn new_pathname(pathname_class: &Class, path: RString) -> RubyResult { + // Safety: `pathname_class` is a class (`Class::from_path` checks), and + // `Pathname` uses the default allocator. + let mut pathname = AnyObject::from(unsafe { rb_obj_alloc(pathname_class.value()) }); + pathname.instance_variable_set("@path", path); + Ok(pathname) +} diff --git a/test/cleanpath_conservative_test.rb b/test/cleanpath_conservative_test.rb index 7b87cfe..345a1de 100644 --- a/test/cleanpath_conservative_test.rb +++ b/test/cleanpath_conservative_test.rb @@ -38,6 +38,12 @@ def test_it_creates_cleanpath_conservative_correctly_part_2 assert_equal FasterPath.cleanpath_conservative("a/b/../../../../c/../d"), "a/b/../../../../c/../d" end + def test_it_keeps_every_name_with_a_trailing_separator + %w[a/b/ /a/b// a/./b/ a/b/../ ./a/b/ a//b/c/].each do |path| + assert_equal Pathname.new(path).cleanpath(true).to_s, FasterPath.cleanpath_conservative(path), path + end + end + def test_windows_compat if DOSISH assert_equal FasterPath.cleanpath_conservative('c:/foo/bar'), 'c:\\foo\\bar' diff --git a/test/extname_test.rb b/test/extname_test.rb index 2fdcc2c..efbefdd 100644 --- a/test/extname_test.rb +++ b/test/extname_test.rb @@ -25,7 +25,8 @@ def test_extname_official appendixes.each do |appendix| infixes.each do |infix| path = "#{prefix}foo#{infix}#{appendix}" - assert_equal("", FasterPath.extname(path), "FasterPath.extname(#{path.inspect})") + expected = infix == "." && appendix.empty? ? TRAILING_DOT_EXTNAME : "" + assert_equal(expected, FasterPath.extname(path), "FasterPath.extname(#{path.inspect})") end infixes2.each do |infix| path = "#{prefix}foo#{infix}.ext#{appendix}" @@ -56,8 +57,8 @@ def test_extname_edge_cases assert_equal "", FasterPath.extname("..") assert_equal "", FasterPath.extname("...") assert_equal "", FasterPath.extname("....") - assert_equal "", FasterPath.extname(".foo.") - assert_equal "", FasterPath.extname("foo.") + assert_equal TRAILING_DOT_EXTNAME, FasterPath.extname(".foo.") + assert_equal TRAILING_DOT_EXTNAME, FasterPath.extname("foo.") assert_equal "", FasterPath.extname("..foo") end @@ -86,5 +87,10 @@ def test_substitutability_of_rust_and_ruby_impls assert_equal( *result_pair.("foo.") ) assert_equal( *result_pair.("foo.rb/") ) assert_equal( *result_pair.("foo.rb//") ) + assert_equal( *result_pair.("..foo") ) + assert_equal( *result_pair.("a/..b") ) + assert_equal( *result_pair.("...a") ) + assert_equal( *result_pair.("foo..") ) + assert_equal( *result_pair.("a./") ) end end diff --git a/test/refinements/absoulte_test.rb b/test/refinements/absoulte_test.rb index 0bf039c..d71b91e 100644 --- a/test/refinements/absoulte_test.rb +++ b/test/refinements/absoulte_test.rb @@ -9,7 +9,7 @@ def absolute?(v) end end -class AbsoluteRefinementTest < Minitest::Test +class RefinementsAbsoluteRefinementTest < Minitest::Test def test_refines_pathname_absolute? assert RefinedPathname.new.absolute?("/") end diff --git a/test/refinements/add_trailing_separator_test.rb b/test/refinements/add_trailing_separator_test.rb index b3776df..4db2518 100644 --- a/test/refinements/add_trailing_separator_test.rb +++ b/test/refinements/add_trailing_separator_test.rb @@ -8,7 +8,7 @@ def add_trailing_separator(pth) end end -class AddTrailingSeparatorTest < Minitest::Test +class RefinementsAddTrailingSeparatorTest < Minitest::Test def test_refines_pathname_add_trailing_separator assert RefinedPathname.allocate.send(:add_trailing_separator, 'hello') end diff --git a/test/refinements/basename_test.rb b/test/refinements/basename_test.rb index 9ce357a..f4207a0 100644 --- a/test/refinements/basename_test.rb +++ b/test/refinements/basename_test.rb @@ -7,7 +7,7 @@ def basename(*args) end end -class BasenameTest < Minitest::Test +class RefinementsBasenameTest < Minitest::Test # Tests copied from https://searchcode.com/codesearch/view/12785140/ def test_it_creates_basename_correctly # Tests for basename diff --git a/test/refinements/chop_basename_test.rb b/test/refinements/chop_basename_test.rb index 3563078..0e50ce4 100644 --- a/test/refinements/chop_basename_test.rb +++ b/test/refinements/chop_basename_test.rb @@ -9,7 +9,7 @@ def chop_basename(v) end end -class ChopBasenameRefinementTest < Minitest::Test +class RefinementsChopBasenameRefinementTest < Minitest::Test def test_refines_pathname_chop_basename assert RefinedPathname.new.chop_basename("/hello/world") end diff --git a/test/refinements/directory_test.rb b/test/refinements/directory_test.rb index a2ac7bc..6815156 100644 --- a/test/refinements/directory_test.rb +++ b/test/refinements/directory_test.rb @@ -8,7 +8,7 @@ def directory?(v) end end -class DirectoryRefinementTest < Minitest::Test +class RefinementsDirectoryRefinementTest < Minitest::Test def test_refines_pathname_directory? assert RefinedPathname.new.directory?("/") end diff --git a/test/refinements/dirname_test.rb b/test/refinements/dirname_test.rb index 0c3a57b..712592a 100644 --- a/test/refinements/dirname_test.rb +++ b/test/refinements/dirname_test.rb @@ -9,7 +9,7 @@ def dirname(name) end end -class DirnameTest < Minitest::Test +class RefinementsDirnameTest < Minitest::Test def setup @refined_file = RefinedFile.new end diff --git a/test/refinements/extname_test.rb b/test/refinements/extname_test.rb index 4603ce5..6cdc5fb 100644 --- a/test/refinements/extname_test.rb +++ b/test/refinements/extname_test.rb @@ -7,7 +7,7 @@ def extname(path) end end -class ExtnameTest < Minitest::Test +class RefinementsExtnameTest < Minitest::Test # Tests copied from https://searchcode.com/codesearch/view/12785140/ def test_extname assert_equal ".rb", RefinedFile.new.extname("foo.rb") @@ -27,7 +27,7 @@ def test_extname_edge_cases assert_equal "", RefinedFile.new.extname("..") assert_equal "", RefinedFile.new.extname("...") assert_equal "", RefinedFile.new.extname("....") - assert_equal "", RefinedFile.new.extname(".foo.") - assert_equal "", RefinedFile.new.extname("foo.") + assert_equal TRAILING_DOT_EXTNAME, RefinedFile.new.extname(".foo.") + assert_equal TRAILING_DOT_EXTNAME, RefinedFile.new.extname("foo.") end end diff --git a/test/refinements/has_trailing_separator_test.rb b/test/refinements/has_trailing_separator_test.rb index 848b135..4e40dcb 100644 --- a/test/refinements/has_trailing_separator_test.rb +++ b/test/refinements/has_trailing_separator_test.rb @@ -5,7 +5,7 @@ def has_trailing_separator?(path) end end -class HasTrailingSeparatorRefinementTest < Minitest::Test +class RefinementsHasTrailingSeparatorRefinementTest < Minitest::Test def test_refines_pathname_has_trailing_separator? assert RefinedPathname.new.has_trailing_separator? "a/" end diff --git a/test/refinements/plus_test.rb b/test/refinements/plus_test.rb index 3335737..94b37d3 100644 --- a/test/refinements/plus_test.rb +++ b/test/refinements/plus_test.rb @@ -7,7 +7,7 @@ def plus(*args) end end -class BasenameTest < Minitest::Test +class RefinementsPlusTest < Minitest::Test # Tests copied from https://searchcode.com/codesearch/view/12785140/ def test_it_creates_basename_correctly assert_equal('/', RefinedPathname.allocate.send(:plus, '/', '/')) diff --git a/test/refinements/relative_test.rb b/test/refinements/relative_test.rb index 29bdd54..720c8e0 100644 --- a/test/refinements/relative_test.rb +++ b/test/refinements/relative_test.rb @@ -9,7 +9,7 @@ def relative?(v) end end -class RelativeRefinementTest < Minitest::Test +class RefinementsRelativeRefinementTest < Minitest::Test def test_refines_pathname_relative? refute RefinedPathname.new.relative?("/") end diff --git a/test/ripple_effects/cleanpath_aggressive_test.rb b/test/ripple_effects/cleanpath_aggressive_test.rb index 1124e17..9de18f3 100644 --- a/test/ripple_effects/cleanpath_aggressive_test.rb +++ b/test/ripple_effects/cleanpath_aggressive_test.rb @@ -9,7 +9,7 @@ def cleanpath_aggressive(pth) end end -class CleanpathAggressiveTest < Minitest::Test +class RippleEffectsCleanpathAggressiveTest < Minitest::Test def test_clean_aggressive_defaults1 assert_equal RefinedPathname.new.cleanpath_aggressive('/'), '/' assert_equal RefinedPathname.new.cleanpath_aggressive(''), '.' diff --git a/test/ripple_effects/cleanpath_conservative_test.rb b/test/ripple_effects/cleanpath_conservative_test.rb index f2f8356..f924d5d 100644 --- a/test/ripple_effects/cleanpath_conservative_test.rb +++ b/test/ripple_effects/cleanpath_conservative_test.rb @@ -9,7 +9,7 @@ def cleanpath_conservative(pth) end end -class CleanpathConservativeTest < Minitest::Test +class RippleEffectsCleanpathConservativeTest < Minitest::Test def test_clean_conservative_defaults1 assert_equal RefinedPathname.new.cleanpath_conservative('/'), '/' assert_equal RefinedPathname.new.cleanpath_conservative(''), '.' diff --git a/test/ripple_effects/del_trailing_separator_test.rb b/test/ripple_effects/del_trailing_separator_test.rb index dfe1549..0f1e0e9 100644 --- a/test/ripple_effects/del_trailing_separator_test.rb +++ b/test/ripple_effects/del_trailing_separator_test.rb @@ -9,7 +9,7 @@ def del_trailing_separator(pth) end end -class DelTrailingSeparatorTest < Minitest::Test +class RippleEffectsDelTrailingSeparatorTest < Minitest::Test def test_del_trailing_separator assert_equal RefinedPathname.new.del_trailing_separator("/"), "/" assert_equal RefinedPathname.new.del_trailing_separator("/a"), "/a" diff --git a/test/ripple_effects/has_trailing_separator_test.rb b/test/ripple_effects/has_trailing_separator_test.rb index a999c7c..c6e6209 100644 --- a/test/ripple_effects/has_trailing_separator_test.rb +++ b/test/ripple_effects/has_trailing_separator_test.rb @@ -9,7 +9,7 @@ def has_trailing_separator?(pth) end end -class HasTrailingSeparatorTest < Minitest::Test +class RippleEffectsHasTrailingSeparatorTest < Minitest::Test def test_has_trailing_separator? refute RefinedPathname.new.has_trailing_separator?("/") refute RefinedPathname.new.has_trailing_separator?("///") diff --git a/test/ripple_effects/join_test.rb b/test/ripple_effects/join_test.rb index 3600a65..4f53b9a 100644 --- a/test/ripple_effects/join_test.rb +++ b/test/ripple_effects/join_test.rb @@ -10,7 +10,7 @@ def join(*args) end end -class JoinTest < Minitest::Test +class RippleEffectsJoinTest < Minitest::Test def test_join r = RefinedPathname.new.join("a", Pathname("b"), Pathname("c")) assert_equal(Pathname("a/b/c"), r) diff --git a/test/ripple_effects/plus_test.rb b/test/ripple_effects/plus_test.rb index b50cb3f..fae83df 100644 --- a/test/ripple_effects/plus_test.rb +++ b/test/ripple_effects/plus_test.rb @@ -13,7 +13,7 @@ def +(pth, other) end end -class CleanpathAggressiveTest < Minitest::Test +class RippleEffectsPlusTest < Minitest::Test def test_plus assert_kind_of(Pathname, RefinedPathname.new.+("a", "b")) end diff --git a/test/safety_test.rb b/test/safety_test.rb new file mode 100644 index 0000000..4ef9831 --- /dev/null +++ b/test/safety_test.rb @@ -0,0 +1,140 @@ +require 'test_helper' + +# Inputs that used to crash the Ruby process (aborts from Rust panics, or +# segfaults) must now return a value or raise a Ruby exception. +class SafetyTest < Minitest::Test + STRING_METHODS = %i[ + absolute? add_trailing_separator basename chop_basename cleanpath_aggressive + cleanpath_conservative del_trailing_separator directory? dirname entries + entries_compat extname has_trailing_separator? plus relative? relative_path_from + children children_compat + ].freeze + + class RaisingPath + def to_path + raise IOError, "to_path failed" + end + + def to_s + raise IOError, "to_s failed" + end + end + + class IntegerPath + def to_path + 42 + end + end + + def call(method, *args) + FasterPath.public_send(method, *args) + rescue StandardError => e + e + end + + def test_join_without_arguments_raises_argument_error + assert_raises(ArgumentError) { FasterPath.join } + end + + def test_join_under_gc_stress + GC.stress = true + result = FasterPath.join(Pathname("a"), "b", Pathname("c"), "d") + GC.stress = false + assert_equal Pathname("a/b/c/d"), result + ensure + GC.stress = false + end + + def test_children_and_entries_under_gc_stress + GC.stress = true + children = FasterPath.children_compat(__dir__, true) + entries = FasterPath.entries_compat(__dir__) + GC.stress = false + assert_equal Pathname(__dir__).children.sort, children.sort + assert_equal Pathname(__dir__).entries.sort, entries.sort + ensure + GC.stress = false + end + + def test_exceptions_from_ruby_code_propagate + assert_raises(IOError) { FasterPath.join("a", RaisingPath.new) } + assert_raises(IOError) { FasterPath.relative_path_from("a", RaisingPath.new) } + assert_raises(IOError) { FasterPath.basename(RaisingPath.new) } + end + + def test_to_path_must_return_a_string + assert_raises(TypeError) { FasterPath.basename(IntegerPath.new) } + assert_raises(TypeError) { FasterPath.join("a", IntegerPath.new) } + end + + def test_to_path_is_used + assert_equal "b", FasterPath.basename(Pathname("a/b")) + assert_equal ".rb", FasterPath.extname(Pathname("a/b.rb")) + assert_equal "a", FasterPath.dirname(Pathname("a/b")) + end + + def test_add_trailing_separator_requires_a_path + assert_raises(TypeError) { FasterPath.add_trailing_separator(nil) } + assert_raises(TypeError) { FasterPath.add_trailing_separator(1) } + assert_equal "a/", FasterPath.add_trailing_separator("a") + end + + def test_too_many_arguments_raise_argument_error + STRING_METHODS.each do |method| + assert_raises(ArgumentError, method.to_s) { FasterPath.public_send(method, "a", "b", "c") } + end + end + + def test_odd_arguments_never_crash + odd = [nil, 1, :sym, Object.new, [], "a\0b", "\xFF\xFE/\xFD".b, "a/b".encode("UTF-16LE"), + RaisingPath.new, IntegerPath.new, "", "/", "//", "."] + STRING_METHODS.each do |method| + odd.each do |a| + call(method, a) + odd.each { |b| call(method, a, b) } + end + end + odd.each { |a| odd.each { |b| call(:join, a, b) } } + end + + def test_null_bytes_raise_argument_error + assert_raises(ArgumentError) { FasterPath.basename("a\0b") } + assert_raises(ArgumentError) { FasterPath.directory?("a\0b") } + assert_raises(ArgumentError) { FasterPath.entries("a\0b") } + end + + def test_non_utf8_paths_keep_their_bytes_and_encoding + path = "\xFF\xFE/\xFD.\xFC".b + assert_equal "\xFD.\xFC".b, FasterPath.basename(path) + assert_equal Encoding::ASCII_8BIT, FasterPath.basename(path).encoding + assert_equal "\xFF\xFE".b, FasterPath.dirname(path) + assert_equal ".\xFC".b, FasterPath.extname(path) + assert_equal ["\xFF\xFE/".b, "\xFD.\xFC".b], FasterPath.chop_basename(path) + assert_equal File.basename(path), FasterPath.basename(path) + assert_equal File.dirname(path), FasterPath.dirname(path) + assert_equal File.extname(path), FasterPath.extname(path) + end + + def test_result_encoding_matches_ruby + path = "dir/fé.rb".encode("ISO-8859-1") + assert_equal File.basename(path).encoding, FasterPath.basename(path).encoding + assert_equal File.dirname(path).encoding, FasterPath.dirname(path).encoding + assert_equal File.extname(path).encoding, FasterPath.extname(path).encoding + end + + def test_missing_directories_raise_errno + missing = File.join(__dir__, "no_such_directory") + error = assert_raises(Errno::ENOENT) { FasterPath.entries(missing) } + assert_includes error.message, missing + assert_raises(Errno::ENOENT) { FasterPath.entries_compat(missing) } + assert_raises(Errno::ENOENT) { FasterPath.children(missing) } + assert_raises(Errno::ENOENT) { FasterPath.children_compat(missing) } + assert_raises(Errno::ENOTDIR) { FasterPath.entries(__FILE__) } + end + + def test_relative_path_from_errors_match_pathname + error = assert_raises(ArgumentError) { FasterPath.relative_path_from("a", "/b") } + pathname_error = assert_raises(ArgumentError) { Pathname("a").relative_path_from(Pathname("/b")) } + assert_equal pathname_error.message, error.message + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3eef859..b9c3bfe 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -34,6 +34,8 @@ def assert_incompatible_encoding end ::Minitest::Test.class_eval do + # Ruby 2.7 changed File.extname("foo.") from "" to "." + TRAILING_DOT_EXTNAME = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7") ? "." : "" DRIVE = Dir.pwd[%r{\A(?:[a-z]:|//[^/]+/[^/]+)}i] POSIX = /cygwin|mswin|bccwin|mingw|emx/ !~ RUBY_PLATFORM NTFS = !(/mingw|mswin|bccwin/ !~ RUBY_PLATFORM) From 81d51950fec954779c39d1cab6b7b3f830daa892 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:14:23 +0000 Subject: [PATCH 02/11] Require Ruby 2.5 to 2.7 and test those in CI Rutie 0.10 supports Ruby 2.5, 2.6 and 2.7 (64-bit only on Windows), so the gemspec now requires those versions, and Travis and AppVeyor test them. The encoding job passes now and is no longer allowed to fail. The ruby/spec and mspec submodules move to the last ruby/spec commit that supports Ruby 2.5; the old ones predate Ruby 2.7 and fail on it without FasterPath loaded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- .appveyor.yml | 14 ++++++++++---- .travis.yml | 35 +++++++++++++++++------------------ README.md | 7 ++++++- faster_path.gemspec | 3 +++ spec/mspec | 2 +- spec/ruby_spec | 2 +- 6 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.appveyor.yml b/.appveyor.yml index 74818f1..1d5d407 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,18 +1,24 @@ # based on rutie and rust_blank .appveyor.yml files +# Rutie 0.10 supports 64-bit RubyInstaller Rubies 2.5 to 2.7 on Windows platform: - - x86 - x64 environment: matrix: - RUST_VERSION: stable BUILD_TARGET: gnu - RUBY_VERSION: 23 + RUBY_VERSION: 25 + - RUST_VERSION: stable + BUILD_TARGET: gnu + RUBY_VERSION: 26 + - RUST_VERSION: stable + BUILD_TARGET: gnu + RUBY_VERSION: 27 - RUST_VERSION: beta BUILD_TARGET: gnu - RUBY_VERSION: 23 + RUBY_VERSION: 27 - RUST_VERSION: nightly BUILD_TARGET: gnu - RUBY_VERSION: 23 + RUBY_VERSION: 27 cache: - target\debug\build - target\debug\deps diff --git a/.travis.yml b/.travis.yml index 4862cc0..72ac801 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,9 @@ cache: - $HOME/.cargo - $TRAVIS_BUILD_DIR/target rvm: -- 2.4.6 -- 2.5.5 -- 2.6.3 +- 2.5.9 +- 2.6.10 +- 2.7.8 env: global: - NO_LINK_RUTIE=1 @@ -24,62 +24,61 @@ env: - RUST_BACKTRACE=1 ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true matrix: allow_failures: - - env: RUST_BACKTRACE=1 ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true - os: linux - rvm: 2.6.3 + rvm: 2.7.8 env: LONG_RUN=20 script: bundle exec rake pbench - os: linux - rvm: 2.4.6 + rvm: 2.5.9 if: tag =~ ^v0 env: - os: linux - rvm: 2.5.5 + rvm: 2.6.10 if: tag =~ ^v0 env: - os: linux - rvm: 2.6.3 + rvm: 2.7.8 if: tag =~ ^v0 env: - os: osx - rvm: 2.4.6 + rvm: 2.5.9 if: tag =~ ^v0 env: - os: osx - rvm: 2.5.5 + rvm: 2.6.10 if: tag =~ ^v0 env: - os: osx - rvm: 2.6.3 + rvm: 2.7.8 if: tag =~ ^v0 env: include: - os: linux - rvm: 2.6.3 + rvm: 2.7.8 env: LONG_RUN=20 script: bundle exec rake pbench - os: linux - rvm: 2.4.6 + rvm: 2.5.9 if: tag =~ ^v0 env: - os: linux - rvm: 2.5.5 + rvm: 2.6.10 if: tag =~ ^v0 env: - os: linux - rvm: 2.6.3 + rvm: 2.7.8 if: tag =~ ^v0 env: - os: osx - rvm: 2.4.6 + rvm: 2.5.9 if: tag =~ ^v0 env: - os: osx - rvm: 2.5.5 + rvm: 2.6.10 if: tag =~ ^v0 env: - os: osx - rvm: 2.6.3 + rvm: 2.7.8 if: tag =~ ^v0 env: before_install: diff --git a/README.md b/README.md index 003c3be..7f296fc 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,11 @@ As mentioned earlier Sprockets, which handles assets, changed away from using `P * Testers and developers are most welcome * Windows & encoding support is underway! +## Requirements + +* Ruby 2.5, 2.6 or 2.7 (the Rust extension is built with [Rutie](https://github.com/danielpclark/rutie) 0.10, which supports these) +* Rust 1.71 or later + ## Installation Ensure Rust is installed: @@ -120,7 +125,7 @@ Ensure Rust is installed: [Rust Downloads](https://www.rust-lang.org/downloads.html) ``` -curl -sSf https://static.rust-lang.org/rustup.sh | sh +curl -sSf https://sh.rustup.rs | sh ``` Add this line to your application's Gemfile: diff --git a/faster_path.gemspec b/faster_path.gemspec index 06b62ca..f3df2fd 100644 --- a/faster_path.gemspec +++ b/faster_path.gemspec @@ -24,6 +24,9 @@ Gem::Specification.new do |spec| spec.extensions = ['ext/Rakefile'] spec.require_paths = ['lib'] + # Rutie 0.10, which the Rust extension is built with, supports Ruby 2.5 to 2.7 + spec.required_ruby_version = ['>= 2.5', '< 3.0'] + spec.add_dependency 'bundler', '>= 1.12' spec.add_dependency 'rake', '~> 12.3' spec.add_dependency 'thermite', '0.13.0' diff --git a/spec/mspec b/spec/mspec index 297309c..0091e8a 160000 --- a/spec/mspec +++ b/spec/mspec @@ -1 +1 @@ -Subproject commit 297309c05b89e1a964595d6b8d67a01fd62966e0 +Subproject commit 0091e8a62e954717cd54641f935eaf1403692041 diff --git a/spec/ruby_spec b/spec/ruby_spec index 77e927d..c503335 160000 --- a/spec/ruby_spec +++ b/spec/ruby_spec @@ -1 +1 @@ -Subproject commit 77e927d97faeaaab699851d6068a5efc323b4b3d +Subproject commit c503335d3d9f6ec6ef24de60a0716c34af69b64f From fcbf6960c418101d3e76951910b8b81adaf69fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:15:37 +0000 Subject: [PATCH 03/11] Run CI on GitHub Actions Travis CI (travis-ci.org) no longer runs and AppVeyor isn't reporting on pull requests, so this replaces both with a GitHub Actions workflow: - Linux: Ruby 2.5, 2.6 and 2.7, each with the regression, monkeypatch and encoding settings Travis used, running `rake test` (cargo tests, minitest, rubocop, pbench and mspec). - macOS: Ruby 2.7, and Windows: Ruby 2.7 (cargo tests and minitest). - On a version tag, build the precompiled extension tarballs and attach them to the GitHub release, as the Travis deploy step did. A fresh `bundle install` no longer worked, so: - The Gemfile declares rubygems.org as its global source. With only a `source` block Bundler couldn't find a minitest for Ruby 2.6. - minitest-reporters is `~> 1.1` instead of exactly 1.1, which crashes with the minitest versions Bundler now picks. The README badge points at the new workflow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- .appveyor.yml | 73 ---------------------------- .github/workflows/ci.yml | 100 +++++++++++++++++++++++++++++++++++++++ .travis.yml | 98 -------------------------------------- Gemfile | 12 ++--- README.md | 3 +- faster_path.gemspec | 2 +- 6 files changed, 108 insertions(+), 180 deletions(-) delete mode 100644 .appveyor.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 1d5d407..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,73 +0,0 @@ -# based on rutie and rust_blank .appveyor.yml files -# Rutie 0.10 supports 64-bit RubyInstaller Rubies 2.5 to 2.7 on Windows -platform: - - x64 -environment: - matrix: - - RUST_VERSION: stable - BUILD_TARGET: gnu - RUBY_VERSION: 25 - - RUST_VERSION: stable - BUILD_TARGET: gnu - RUBY_VERSION: 26 - - RUST_VERSION: stable - BUILD_TARGET: gnu - RUBY_VERSION: 27 - - RUST_VERSION: beta - BUILD_TARGET: gnu - RUBY_VERSION: 27 - - RUST_VERSION: nightly - BUILD_TARGET: gnu - RUBY_VERSION: 27 -cache: - - target\debug\build - - target\debug\deps - - '%USERPROFILE%\.cargo' -install: - - ps: | - $env:PATH += ";C:\rust\bin"; - if ($env:platform -eq 'x86') { - $env:RUBY_DIR = "Ruby${env:RUBY_VERSION}" - $arch_expanded = "i686-pc-windows-${env:BUILD_TARGET}"; - $env:ARCH = "x86"; - $env:bits = "32"; - } else { - $env:RUBY_DIR = "Ruby${env:RUBY_VERSION}-x64" - $arch_expanded = "x86_64-pc-windows-${env:BUILD_TARGET}"; - $env:ARCH = "amd64"; - $env:bits ="64"; - } - $env:WIN_RUBY_BIN = "C:\${env:RUBY_DIR}\bin"; - $env:PATH = "${env:WIN_RUBY_BIN};${env:PATH}"; - if ($env:BUILD_TARGET -eq 'gnu') { - $env:PATH += ";C:\msys64\mingw${env:bits}\bin"; - gcc --version; - } - if ($env:RUST_VERSION -eq 'stable') { - echo "Downloading $channel channel manifest"; - Start-FileDownload "https://static.rust-lang.org/dist/channel-rust-stable"; - - $env:RUST_VERSION = Get-Content channel-rust-stable | Select -first 1 | %{$_.split('-')[1]} - } - $env:rust_installer = "rust-${env:RUST_VERSION}-${arch_expanded}.exe"; - $tag_suffix = echo "${env:APPVEYOR_REPO_TAG_NAME}" | Select-String -pattern "-rust$" - if ($tag_suffix) { - $env:RUST_TAG = "1"; - } - - curl --show-error --location --retry 5 --output rust-installer.exe https://static.rust-lang.org/dist/%rust_installer% - - .\rust-installer.exe /VERYSILENT /NORESTART /DIR="C:\rust" - - call "C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\vcvarsall.bat" %ARCH% - - rustc -vV - - cargo -vV - - ruby --version - - gem --version - - rake --version - - bundle --version - - bundle config --local path vendor/bundle - - bundle install -build: false -test_script: - - bundle exec rake --trace test -matrix: - allow_failures: - - RUST_VERSION: nightly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3b57a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + RUST_BACKTRACE: '1' + +jobs: + test: + name: Ruby ${{ matrix.ruby }} on ${{ matrix.os }} (${{ matrix.suite.name }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Rutie 0.10, which the extension is built with, supports Ruby 2.5 to 2.7 + os: [ubuntu-22.04] + ruby: ['2.5', '2.6', '2.7'] + suite: + - name: regression + env: WITH_REGRESSION=true + - name: monkeypatches + env: TEST_MONKEYPATCHES=true WITH_REGRESSION=true + - name: encoding + env: ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true + include: + - os: macos-latest + ruby: '2.7' + suite: + name: regression + env: WITH_REGRESSION=true + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: System details + run: bundle exec rake sysinfo + - name: Test (cargo, minitest, rubocop, pbench, mspec) + run: env ${{ matrix.suite.env }} bundle exec rake test + + windows: + name: Ruby ${{ matrix.ruby }} on windows-latest + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + ruby: ['2.7'] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Test (cargo, minitest) + env: + WITH_REGRESSION: 'true' + run: bundle exec rake cargo minitest + + # Builds the precompiled extension tarballs that `gem install` downloads, + # and attaches them to the GitHub release of a version tag. + release: + name: Release tarball for Ruby ${{ matrix.ruby }} on ${{ matrix.os }} + if: startsWith(github.ref, 'refs/tags/v') + needs: [test, windows] + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + matrix: + os: [ubuntu-22.04] + ruby: ['2.5', '2.6', '2.7'] + include: + - os: macos-latest + ruby: '2.7' + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake thermite:tarball + - uses: softprops/action-gh-release@v2 + with: + files: faster_path-*.tar.gz diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 72ac801..0000000 --- a/.travis.yml +++ /dev/null @@ -1,98 +0,0 @@ -sudo: false -language: ruby -os: -- linux -- osx -cache: - bundler: true - directories: - - $HOME/.cargo - - $TRAVIS_BUILD_DIR/target -rvm: -- 2.5.9 -- 2.6.10 -- 2.7.8 -env: - global: - - NO_LINK_RUTIE=1 - - RUST_BACKTRACE=full - - PATH=$PATH:$HOME/.cargo/bin - - secure: UCarEfq9wDpD6FV0dIdfDMCWfeLoKBAM2PS+xZ82K1uoFBMtwet/UuEuz06OnX6B9k2TwV0aqQpkUuo6o+h9YoHKAGz1/O5iB5uJaHcoII8yL43LH4YUADqupsgbvFzUCdcLuKCvDVG5RD96tt5XX87UdyZMgHvBV41wKOoUF+0ZyIWq8sKFPlItM+oYN55gY+PZ5ZadsOOkWDbTAG/LuxDi1GJxb/ObiYkjk4b93xzKEx1t7H2LSs8/f8dg0w1gnM6bVXQHjdXGuAw8jqy6IoszFh2oKDyttix4yB5l5xxj/NwO/aJNmOLBRNf611WskH6RLKWteCxwyY0gIZnDlC7q+SMYJtDapuffvqqKdlC5ECcVNimw9D3DwuxizP/5IGoC5+X+RIJiWBx/RJgFV2dw22/XvC62rOMttQL1K1dUByMRJL1pjLoauofbTdJgZgdrb1eDxGNyxL+Rg8za2wqs5NtENnXY6RIX4NvSXUPE9vRLdYnmGtH5hLF36hRh5AwvgOZ544xYT2ss6FxUJNo/zphlE7zfZrD+waeUBRUabzi6S5xEBXainj01UjtfVXRDtcjV9vbh5pYYjZsX8lLZAdW8OZeKxhaOv7zQyUtAwMHE/sGfKj1yQSDh2fscY48mepV7xUPVE7jRgUDk1ayt2a+WeGE2DCR3KBv5gkk= - matrix: - - WITH_REGRESSION=true - - TEST_MONKEYPATCHES=true WITH_REGRESSION=true - - RUST_BACKTRACE=1 ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true -matrix: - allow_failures: - - os: linux - rvm: 2.7.8 - env: LONG_RUN=20 - script: bundle exec rake pbench - - os: linux - rvm: 2.5.9 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.6.10 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.7.8 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.5.9 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.6.10 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.7.8 - if: tag =~ ^v0 - env: - include: - - os: linux - rvm: 2.7.8 - env: LONG_RUN=20 - script: bundle exec rake pbench - - os: linux - rvm: 2.5.9 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.6.10 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.7.8 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.5.9 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.6.10 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.7.8 - if: tag =~ ^v0 - env: -before_install: -- curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable -- gem install bundler -deploy: - provider: releases - api_key: - secure: NhCKpRLm/aOLOoFuoXHkc1AgNIoIRlVrZs08VxJ7Hbjw0/iUX4Uq+0DloX9NVRKupd3oVH/p6gNnPQEkOdpDmx+4x8KdlwX3uYLOQ8LZTjjarOb+3StDsl748Zm1dnO69SkVHZPQ6m6W2ITg065eH82pF83QUp6Th7MLuF8f9F4Dlgdt4thHtqU43VWWRGQAT0/haLZhdKczCQJDuWrDbPHNCZwgCdm59htSmQHL1FyWAehymGy+iI9fKvs+ZifRqV/PxvC3nJSEKFbFW/aq/1LvA0AL2GKjClODiklDGdMoDsFFH6t889ZYMgIzOITP7O3cpN2+Y5tMy03U8Yf4hKg8M0fPoA1ufJ2k+OGNEFOcLeWfbwZrdLEBsm+U8vZ/r2pvz4837IA/vrHbFUFnT8ynv+JZfyaNwKNh7durRgOJYHpwsxqe/PSjlQocL1Z0FBeMVKQYViIz4g/rgBzQHwyrzou6FSGhk3kYRMD2+gPuZ+pj/hnWXc/fF3UvtAcFOxuQrk07OA7T9sZhHhA+/lQCYhVNA1YQen01dkX1yYmA+jpZvZ3p6gyhmvHksEmrJIu912QPMWFehtjPlF4te7FppQYDdjBpyePIZs0GLz6Puf5YjVu6E2knfHQzTIM3nvSko17D9fDv7d/1oXuMgiOlM0YzRPeEtr6TtGwee98= - file: faster_path-*.tar.gz - file_glob: true - skip_cleanup: true - on: - condition: -z ${TEST_MONKEYPATCHES} - repo: danielpclark/faster_path - tags: true -after_success: bundle exec rake thermite:tarball diff --git a/Gemfile b/Gemfile index 6ce00d2..31d1a72 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,8 @@ -source 'https://rubygems.org' do - # Specify your gem's dependencies in faster_path.gemspec - gemspec +source 'https://rubygems.org' - group :test do - gem 'coveralls', require: false - end +# Specify your gem's dependencies in faster_path.gemspec +gemspec + +group :test do + gem 'coveralls', require: false end diff --git a/README.md b/README.md index 7f296fc..b336a38 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # FasterPath [![Gem Version](https://badge.fury.io/rb/faster_path.svg)](https://badge.fury.io/rb/faster_path) -[![TravisCI Build Status](https://travis-ci.org/danielpclark/faster_path.svg?branch=master)](https://travis-ci.org/danielpclark/faster_path) -[![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/10ul0gk3cwhlt2lj/branch/master?svg=true)](https://ci.appveyor.com/project/danielpclark/faster-path/branch/master) +[![CI](https://github.com/danielpclark/faster_path/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/danielpclark/faster_path/actions/workflows/ci.yml) [![Latest Tag](https://img.shields.io/github/tag/danielpclark/faster_path.svg)](https://github.com/danielpclark/faster_path/tags) [![Commits Since Last Release](https://img.shields.io/github/commits-since/danielpclark/faster_path/v0.3.10.svg)](https://github.com/danielpclark/faster_path/pulse) [![Binary Release](https://img.shields.io/github/release/danielpclark/faster_path.svg)](https://github.com/danielpclark/faster_path/releases) diff --git a/faster_path.gemspec b/faster_path.gemspec index f3df2fd..a66e525 100644 --- a/faster_path.gemspec +++ b/faster_path.gemspec @@ -32,7 +32,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'thermite', '0.13.0' spec.add_development_dependency 'read_source', '~> 0.2.6' spec.add_development_dependency 'minitest', '~> 5.11' - spec.add_development_dependency 'minitest-reporters', '1.1' + spec.add_development_dependency 'minitest-reporters', '~> 1.1' spec.add_development_dependency 'color_pound_spec_reporter', '~> 0.0.9' spec.add_development_dependency 'rubocop', '0.53' spec.add_development_dependency 'stop_watch', '~> 1.0' From 5e4bedfa4ef03ecfb0cd52e2983c28a4af22b4e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:42:45 +0000 Subject: [PATCH 04/11] Support Windows path rules like Ruby does FasterPath split paths only on `std::path::MAIN_SEPARATOR`, so on Windows it ignored `/` (Ruby's `File::SEPARATOR` there too) and the library failed most of its own tests. The path functions are now ports of Ruby's own (`file.c` for `basename`, `dirname`, `extname` and `File.join`; `pathname.rb` for the `Pathname` helpers), for both sets of rules Ruby is built with: - Unix: `/` is the only separator. - Windows: `/` and `\` are separators, drive letters ("C:") and UNC prefixes ("//server/share") are kept whole, NTFS ignores trailing dots, spaces and `:stream` names at the end of a file name, and names compare case-insensitively. Both sets of rules are always compiled and unit tested, so the Windows rules are tested on every platform; the extension uses the ones of the platform it is built for. Paths are joined with `/` everywhere, as `File.join` does. In encodings such as cp932, `\` can be the second byte of a character; Windows Ruby doesn't take that for a separator, and neither does FasterPath now. On Windows, paths are converted to UTF-8 for the OS and file names to the filesystem encoding, as Ruby's `Dir` does. `children` joins names with `File.join` semantics instead of the OS separator. Every method was compared with Ruby's own on 200,000 to 100,000 random paths on Ruby 2.5, 2.6 and 2.7 (Linux): all results match. CI runs the full `rake test` on Windows for Ruby 2.5 to 2.7 too, with `rake test` running mspec through Ruby so it works there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- .github/workflows/ci.yml | 28 +--- Rakefile | 4 +- src/basename.rs | 153 +++++++++++++----- src/chop_basename.rs | 70 ++++++++- src/cleanpath_aggressive.rs | 75 +++++---- src/cleanpath_conservative.rs | 155 ++++++++++++------- src/dirname.rs | 100 +++++++++--- src/extname.rs | 105 ++++++++++--- src/helpers.rs | 11 -- src/lib.rs | 1 - src/path_parsing.rs | 281 ++++++++++++++++++++++++++++++---- src/pathname.rs | 121 ++++++++------- src/plus.rs | 116 ++++++++------ src/prepend_prefix.rs | 21 ++- src/relative_path_from.rs | 53 ++++--- src/ruby.rs | 114 ++++++++++++-- test/test_helper.rb | 9 +- 17 files changed, 1017 insertions(+), 400 deletions(-) delete mode 100644 src/helpers.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3b57a0..72e84fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,14 @@ jobs: test: name: Ruby ${{ matrix.ruby }} on ${{ matrix.os }} (${{ matrix.suite.name }}) runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash strategy: fail-fast: false matrix: # Rutie 0.10, which the extension is built with, supports Ruby 2.5 to 2.7 - os: [ubuntu-22.04] + os: [ubuntu-22.04, windows-latest] ruby: ['2.5', '2.6', '2.7'] suite: - name: regression @@ -50,33 +53,12 @@ jobs: - name: Test (cargo, minitest, rubocop, pbench, mspec) run: env ${{ matrix.suite.env }} bundle exec rake test - windows: - name: Ruby ${{ matrix.ruby }} on windows-latest - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - ruby: ['2.7'] - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: dtolnay/rust-toolchain@stable - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: Test (cargo, minitest) - env: - WITH_REGRESSION: 'true' - run: bundle exec rake cargo minitest - # Builds the precompiled extension tarballs that `gem install` downloads, # and attaches them to the GitHub release of a version tag. release: name: Release tarball for Ruby ${{ matrix.ruby }} on ${{ matrix.os }} if: startsWith(github.ref, 'refs/tags/v') - needs: [test, windows] + needs: [test] runs-on: ${{ matrix.os }} permissions: contents: write diff --git a/Rakefile b/Rakefile index b25c822..1220540 100644 --- a/Rakefile +++ b/Rakefile @@ -69,7 +69,9 @@ task :init_mspec do |_t| end task test: [:cargo, :minitest, :lint, :pbench, :init_mspec] do |_t| - exec 'spec/mspec/bin/mspec --format spec core/file/basename core/file/extname core/file/dirname library/pathname' + # Run through Ruby so this also works on Windows + exec Gem.ruby, 'spec/mspec/bin/mspec', '--format', 'spec', + 'core/file/basename', 'core/file/extname', 'core/file/dirname', 'library/pathname' end desc "Full mspec results w/o encoding" diff --git a/src/basename.rs b/src/basename.rs index f8ddfc7..0917423 100644 --- a/src/basename.rs +++ b/src/basename.rs @@ -1,55 +1,87 @@ use memchr::memrchr; -use crate::path_parsing::{find_last_sep_pos, find_last_non_sep_pos, SEP_BYTES}; - -pub fn basename<'a>(path: &'a [u8], ext: &[u8]) -> &'a [u8] { - let base = last_component(path); - &base[..ext_end(base, ext)] -} - -// The last component of the path, without trailing separators; "/" when -// there are only separators. -pub fn last_component(path: &[u8]) -> &[u8] { - let mut left: usize = 0; - let mut right: usize = path.len(); - if let Some(last_slash_pos) = find_last_sep_pos(path) { - if last_slash_pos == right - 1 { - if let Some(pos) = find_last_non_sep_pos(&path[..last_slash_pos]) { - right = pos + 1; - } else { - return SEP_BYTES; - } - if let Some(pos) = find_last_sep_pos(&path[..right]) { - left = pos + 1; - } - } else { - left = last_slash_pos + 1; - } +use crate::path_parsing::{Rules, SEP_BYTES}; + +// Ruby's `File.basename(path, ext)`. +pub fn basename<'a>(rules: Rules, path: &'a [u8], ext: &[u8]) -> &'a [u8] { + let (name, base_len) = last_component(rules, path); + match base_len { + Some(base_len) => &name[..ext_end(rules, name, base_len, ext)], + None => name, } - &path[left..right] } -// Where `slice` ends without the extension `ext`. `ext` is either an -// extension to remove, or a byte followed by `*` to remove everything from -// the last occurrence of that byte, such as ".*". -pub fn ext_end(slice: &[u8], ext: &[u8]) -> usize { - if ext.len() >= slice.len() || slice == b"." || slice == b".." { - return slice.len(); +// Ruby's `ruby_enc_find_basename`: the last component of the path, without +// trailing separators (and on Windows, trailing dots, spaces and `:stream`), +// and where that component ends without its extension. A path that is only +// separators and prefixes has no extension: its "component" is a separator, +// or "" for a drive letter. +pub fn last_component(rules: Rules, path: &[u8]) -> (&[u8], Option) { + if path.is_empty() { + return (path, None); } - if let [first, b'*'] = *ext { - match memrchr(first, slice) { - Some(end) if end != 0 => return end, - _ => {} - }; - } else if slice.ends_with(ext) { - return slice.len() - ext.len(); + let root = rules.skip_prefix(path); + let mut name = root; + while name < path.len() && rules.is_sep(path[name]) { + name += 1; + } + if name == path.len() { + if !rules.is_dosish() || name != root { + // The last separator + return (&path[name - 1..name], None); + } + if path[name - 1] == b':' { + // A drive letter + return (&path[name..], None); + } + // A UNC prefix + return (SEP_BYTES, None); } - slice.len() + let start = match rules.last_separator(&path[name..]) { + Some(pos) => { + let mut start = name + pos; + while rules.is_sep(path[start]) { + start += 1; + } + start + } + None => name, + }; + let rest = &path[start..]; + let len = if rules.is_dosish() { rules.ntfs_tail(rest) } else { rules.chomp_dir_sep(rest) }; + let component = &rest[..len]; + let dots = component.iter().take_while(|&&c| c == b'.').count(); + let base_len = component[dots..].iter().rposition(|&c| c == b'.').map_or(len, |pos| dots + pos); + (component, Some(base_len)) +} + +// Ruby's `rmext`: where `component` ends without the extension `ext`, or +// its length if `ext` isn't there. `ext` is an extension to remove, ".*" +// for any extension, or a character followed by `*` to remove everything +// from the last occurrence of that character. +pub fn ext_end(rules: Rules, component: &[u8], base_len: usize, ext: &[u8]) -> usize { + let len = component.len(); + let end = match *ext { + [] => 0, + [b'.', b'*'] => base_len, + [c, b'*'] => memrchr(c, component).unwrap_or(len), + _ if len < ext.len() => len, + _ => { + let start = len - ext.len(); + if rules.same_path(&component[start..], ext) { start } else { 0 } + } + }; + if end == 0 { len } else { end } } #[cfg(test)] fn basename_str<'a>(path: &'a str, ext: &str) -> &'a str { - std::str::from_utf8(basename(path.as_bytes(), ext.as_bytes())).unwrap() + std::str::from_utf8(basename(Rules::UNIX, path.as_bytes(), ext.as_bytes())).unwrap() +} + +#[cfg(test)] +fn windows_basename<'a>(path: &'a str, ext: &str) -> &'a str { + std::str::from_utf8(basename(Rules::WINDOWS, path.as_bytes(), ext.as_bytes())).unwrap() } #[test] @@ -155,3 +187,42 @@ fn trailing_slashes_relative_root() { fn edge_case_all_seps() { assert_eq!("/", basename_str("///", ".*")); } + +#[test] +fn ext_compared_like_ruby() { + assert_eq!(basename_str("..", "."), "."); + assert_eq!(basename_str("a.rb", ".RB"), "a.rb"); + assert_eq!(windows_basename("a.rb", ".RB"), "a"); +} + +#[test] +fn windows_separators() { + assert_eq!(windows_basename("a\\b", ""), "b"); + assert_eq!(windows_basename("a\\b\\", ""), "b"); + assert_eq!(windows_basename("a/b\\c", ""), "c"); + assert_eq!(windows_basename("\\", ""), "\\"); + assert_eq!(basename_str("a\\b", ""), "a\\b"); +} + +#[test] +fn windows_prefixes() { + assert_eq!(windows_basename("C:", ""), ""); + assert_eq!(windows_basename("C:/", ""), "/"); + assert_eq!(windows_basename("C:/a", ""), "a"); + assert_eq!(windows_basename("C:a", ""), "a"); + assert_eq!(windows_basename("//a/b", ""), "/"); + assert_eq!(windows_basename("//a/b/", ""), "/"); + assert_eq!(windows_basename("//a/b/c", ""), "c"); + assert_eq!(basename_str("//a/b", ""), "b"); +} + +#[test] +fn windows_ntfs_names() { + assert_eq!(windows_basename("foo.test ", ""), "foo.test"); + assert_eq!(windows_basename("foo.test.", ""), "foo.test"); + assert_eq!(windows_basename("foo.test::$DATA", ""), "foo.test"); + assert_eq!(windows_basename("foo.test ", ".test"), "foo"); + assert_eq!(windows_basename("foo.test.", ".*"), "foo"); + assert_eq!(windows_basename("foo.test::$DATA", ".*"), "foo"); + assert_eq!(windows_basename("...", ""), "..."); +} diff --git a/src/chop_basename.rs b/src/chop_basename.rs index eb5a0da..7d5b6f1 100644 --- a/src/chop_basename.rs +++ b/src/chop_basename.rs @@ -1,17 +1,35 @@ -use crate::path_parsing::{find_last_non_sep_pos, find_last_sep_pos}; +use memchr::memmem; -pub fn chop_basename(input: &[u8]) -> Option<(&[u8], &[u8])> { - let len = find_last_non_sep_pos(input)? + 1; - let base_start = find_last_sep_pos(&input[..len]).map_or(0, |pos| pos + 1); - if base_start == len { +use crate::basename::basename; +use crate::path_parsing::{offset_in, Rules}; + +// Pathname's `chop_basename`: the path split before its basename, unless +// the basename is empty or a separator. +pub fn chop_basename(rules: Rules, path: &[u8]) -> Option<(&[u8], &[u8])> { + let base = basename(rules, path, b""); + if base.is_empty() || (base.len() == 1 && rules.is_sep(base[0])) { return None; } - Some((&input[0..base_start], &input[base_start..len])) + // `path.rindex(base)`. On Unix, nothing after the basename can contain it. + let start = if rules.is_dosish() { memmem::rfind(path, base)? } else { offset_in(path, base) }; + Some((&path[..start], &path[start..start + base.len()])) +} + +// Pathname's `relative?`: whether chopping every basename off leaves nothing. +pub fn is_relative(rules: Rules, path: &[u8]) -> bool { + if !rules.is_dosish() { + return path.first() != Some(&b'/'); + } + let mut prefix = path; + while let Some((rest, _)) = chop_basename(rules, prefix) { + prefix = rest; + } + prefix.is_empty() } #[cfg(test)] fn chop_basename_str(input: &str) -> Option<(&str, &str)> { - chop_basename(input.as_bytes()).map(|(dirname, basename)| { + chop_basename(Rules::UNIX, input.as_bytes()).map(|(dirname, basename)| { (std::str::from_utf8(dirname).unwrap(), std::str::from_utf8(basename).unwrap()) }) } @@ -35,5 +53,41 @@ fn it_chops_the_basename_and_dirname() { #[test] fn it_chops_non_utf8_paths() { - assert_eq!(chop_basename(b"\xff/\xfe\xfd/"), Some((&b"\xff/"[..], &b"\xfe\xfd"[..]))); + assert_eq!(chop_basename(Rules::UNIX, b"\xff/\xfe\xfd/"), Some((&b"\xff/"[..], &b"\xfe\xfd"[..]))); +} + +#[cfg(test)] +fn windows_chop(input: &str) -> Option<(&str, &str)> { + chop_basename(Rules::WINDOWS, input.as_bytes()).map(|(dirname, basename)| { + (std::str::from_utf8(dirname).unwrap(), std::str::from_utf8(basename).unwrap()) + }) +} + +#[test] +fn it_chops_windows_paths() { + assert_eq!(windows_chop("a\\b"), Some(("a\\", "b"))); + assert_eq!(windows_chop("a\\b\\"), Some(("a\\", "b"))); + assert_eq!(windows_chop("C:"), None); + assert_eq!(windows_chop("C:/"), None); + assert_eq!(windows_chop("C:/a"), Some(("C:/", "a"))); + assert_eq!(windows_chop("C:a"), Some(("C:", "a"))); + assert_eq!(windows_chop("//a/b"), None); + assert_eq!(windows_chop("//a/b/c"), Some(("//a/b/", "c"))); + assert_eq!(windows_chop("a."), Some(("", "a"))); +} + +#[test] +fn it_knows_relative_paths() { + for &rules in &[Rules::UNIX, Rules::WINDOWS] { + assert!(is_relative(rules, b"")); + assert!(is_relative(rules, b"a")); + assert!(is_relative(rules, b"a/b")); + assert!(!is_relative(rules, b"/")); + assert!(!is_relative(rules, b"/a")); + } + assert!(is_relative(Rules::UNIX, b"C:/a")); + assert!(is_relative(Rules::UNIX, b"\\a")); + for path in &["A:", "A:/", "A:/a", "//", "//a", "//a/", "//a/b", "//a/b/", "//a/b/c", "\\a"] { + assert!(!is_relative(Rules::WINDOWS, path.as_bytes()), "{}", path); + } } diff --git a/src/cleanpath_aggressive.rs b/src/cleanpath_aggressive.rs index 33e6bed..8ffb25b 100644 --- a/src/cleanpath_aggressive.rs +++ b/src/cleanpath_aggressive.rs @@ -1,13 +1,15 @@ use std::borrow::Cow; -use crate::prepend_prefix::prepend_prefix; + use crate::basename::basename; use crate::chop_basename::chop_basename; -use crate::path_parsing::{SEP_BYTES, contains_sep}; +use crate::path_parsing::{Rules, SEP_BYTES}; +use crate::prepend_prefix::prepend_prefix; -pub fn cleanpath_aggressive(path: &[u8]) -> Cow<'_, [u8]> { +// Pathname's `cleanpath_aggressive` +pub fn cleanpath_aggressive(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { let mut names: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((p, base)) = chop_basename(prefix) { + while let Some((p, base)) = chop_basename(rules, prefix) { prefix = p; match base { b"." => {} @@ -21,23 +23,33 @@ pub fn cleanpath_aggressive(path: &[u8]) -> Cow<'_, [u8]> { } } } - // // Windows Feature - // - // ```ruby - // pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR - // ``` - // - if contains_sep(basename(prefix, b"")) { + let prefix = with_separators(rules, prefix); + if rules.contains_sep(basename(rules, &prefix, b"")) { let len = names.iter().rposition(|&c| c != b"..").map_or(0, |pos| pos + 1); names.truncate(len); } names.reverse(); - prepend_prefix(prefix, &names.join(SEP_BYTES)) + let path = prepend_prefix(rules, &prefix, &names.join(SEP_BYTES)); + Cow::Owned(path.into_owned()) +} + +// `pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR)` +pub fn with_separators(rules: Rules, prefix: &[u8]) -> Cow<'_, [u8]> { + if rules.is_dosish() && prefix.contains(&b'\\') { + Cow::Owned(prefix.iter().map(|&c| if c == b'\\' { b'/' } else { c }).collect()) + } else { + Cow::Borrowed(prefix) + } } #[cfg(test)] fn cleanpath_aggressive_str(path: &str) -> String { - String::from_utf8(cleanpath_aggressive(path.as_bytes()).into_owned()).unwrap() + String::from_utf8(cleanpath_aggressive(Rules::UNIX, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_cleanpath_aggressive(path: &str) -> String { + String::from_utf8(cleanpath_aggressive(Rules::WINDOWS, path.as_bytes()).into_owned()).unwrap() } #[test] @@ -76,22 +88,21 @@ fn it_aggressively_cleans_the_path() { assert_eq!(cleanpath_aggressive_str("a/b/../../../../c/../d"), "../../d"); } -// Future Windows Support -// -// DOSISH = File::ALT_SEPARATOR != nil -// DOSISH_DRIVE_LETTER = File.dirname("A:") == "A:." -// DOSISH_UNC = File.dirname("//") == "//" -// -// if DOSISH_UNC -// defassert(:cleanpath_aggressive, '//a/b/c', '//a/b/c/') -// else -// defassert(:cleanpath_aggressive, '/', '///') -// defassert(:cleanpath_aggressive, '/a', '///a') -// defassert(:cleanpath_aggressive, '/', '///..') -// defassert(:cleanpath_aggressive, '/', '///.') -// defassert(:cleanpath_aggressive, '/', '///a/../..') -// end -// -// if DOSISH -// defassert(:cleanpath_aggressive, 'c:/foo/bar', 'c:\\foo\\bar') -// end +#[test] +fn it_cleans_like_ruby_on_unix() { + assert_eq!(cleanpath_aggressive_str("///"), "/"); + assert_eq!(cleanpath_aggressive_str("///a"), "/a"); + assert_eq!(cleanpath_aggressive_str("///.."), "/"); + assert_eq!(cleanpath_aggressive_str("///."), "/"); + assert_eq!(cleanpath_aggressive_str("///a/../.."), "/"); +} + +#[test] +fn it_cleans_like_ruby_on_windows() { + assert_eq!(windows_cleanpath_aggressive("//a/b/c/"), "//a/b/c"); + assert_eq!(windows_cleanpath_aggressive("c:\\foo\\bar"), "c:/foo/bar"); + assert_eq!(windows_cleanpath_aggressive("c:\\foo\\..\\..\\bar"), "c:/bar"); + assert_eq!(windows_cleanpath_aggressive("a\\.\\b"), "a/b"); + assert_eq!(windows_cleanpath_aggressive("C:"), "C:."); + assert_eq!(windows_cleanpath_aggressive("C:a/../b"), "C:b"); +} diff --git a/src/cleanpath_conservative.rs b/src/cleanpath_conservative.rs index ea5f3c4..cd6abe3 100644 --- a/src/cleanpath_conservative.rs +++ b/src/cleanpath_conservative.rs @@ -1,66 +1,98 @@ use std::borrow::Cow; -use crate::prepend_prefix::prepend_prefix; + use crate::basename::basename; -use crate::dirname::dirname; use crate::chop_basename::chop_basename; -use crate::path_parsing::{SEP, SEP_BYTES, contains_sep}; +use crate::cleanpath_aggressive::with_separators; +use crate::dirname::dirname; +use crate::path_parsing::{Rules, SEP_BYTES}; +use crate::prepend_prefix::prepend_prefix; -pub fn cleanpath_conservative(path: &[u8]) -> Cow<'_, [u8]> { +// Pathname's `cleanpath_conservative` +pub fn cleanpath_conservative(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { let mut names: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((p, base)) = chop_basename(prefix) { + while let Some((p, base)) = chop_basename(rules, prefix) { prefix = p; if base != b"." { names.push(base); } } - // // Windows Feature - // - // ```ruby - // pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR - // ``` - // - if contains_sep(basename(prefix, b"")) { + let prefix = with_separators(rules, prefix); + if rules.contains_sep(basename(rules, &prefix, b"")) { let len = names.iter().rposition(|&c| c != b"..").map_or(0, |pos| pos + 1); names.truncate(len); } if names.is_empty() { - return dirname(prefix).into(); + return Cow::Owned(dirname(rules, &prefix).into_owned()); } names.reverse(); - if names.last() != Some(&&b".."[..]) && basename(path, b"") == b"." { + if names.last() != Some(&&b".."[..]) && basename(rules, path, b"") == b"." { names.push(b"."); } - let result = prepend_prefix(prefix, &names.join(SEP_BYTES)); + let result = prepend_prefix(rules, &prefix, &names.join(SEP_BYTES)).into_owned(); match names.last() { - Some(&b".") | Some(&b"..") => result, - _ if has_trailing_separator(path) => add_trailing_separator(result), - _ => result, + Some(&b".") | Some(&b"..") => Cow::Owned(result), + _ if has_trailing_separator(rules, path) => Cow::Owned(add_trailing_separator(rules, Cow::Owned(result)).into_owned()), + _ => Cow::Owned(result), } } -pub fn has_trailing_separator(path: &[u8]) -> bool { - match chop_basename(path) { +// Pathname's `has_trailing_separator?` +pub fn has_trailing_separator(rules: Rules, path: &[u8]) -> bool { + match chop_basename(rules, path) { Some((dirname, basename)) => dirname.len() + basename.len() < path.len(), None => false, } } -// Ruby: `File.basename(path + 'a') == 'a' ? path : File.join(path, '')` -pub fn add_trailing_separator(path: Cow<'_, [u8]>) -> Cow<'_, [u8]> { - if path.is_empty() || path.last() == Some(&SEP) { +// Pathname's `add_trailing_separator`: +// `File.basename(path + 'a') == 'a' ? path : File.join(path, '')` +pub fn add_trailing_separator(rules: Rules, path: Cow<'_, [u8]>) -> Cow<'_, [u8]> { + let ends_a_path = if rules.is_dosish() { + basename(rules, &[&path[..], b"a"].concat(), b"") == b"a" + } else { + path.last().map_or(true, |&c| rules.is_sep(c)) + }; + if ends_a_path { path } else { - let mut path = path.into_owned(); - path.push(SEP); - path.into() + Cow::Owned(rules.join(&path, b"")) + } +} + +// Pathname's `del_trailing_separator` +pub fn del_trailing_separator(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { + if let Some((dirname, basename)) = chop_basename(rules, path) { + // The basename follows the dirname + return Cow::Borrowed(&path[..dirname.len() + basename.len()]); + } + match rules.last_non_sep_pos(path) { + // No trailing separators + _ if path.last().map_or(true, |&c| !rules.is_sep(c)) => Cow::Borrowed(path), + // `$` + File.dirname(path)[/#{SEPARATOR_PAT}*\z/]` + last => { + let before = last.map_or(0, |pos| pos + 1); + let dir = dirname(rules, path); + let separators = dir.len() - dir.iter().rev().take_while(|&&c| rules.is_sep(c)).count(); + Cow::Owned([&path[..before], &dir[separators..]].concat()) + } } } #[cfg(test)] fn cleanpath_conservative_str(path: &str) -> String { - String::from_utf8(cleanpath_conservative(path.as_bytes()).into_owned()).unwrap() + String::from_utf8(cleanpath_conservative(Rules::UNIX, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_cleanpath_conservative(path: &str) -> String { + String::from_utf8(cleanpath_conservative(Rules::WINDOWS, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn del_str(rules: Rules, path: &str) -> String { + String::from_utf8(del_trailing_separator(rules, path.as_bytes()).into_owned()).unwrap() } #[test] @@ -73,19 +105,19 @@ fn it_keeps_the_whole_path_with_a_trailing_separator() { #[test] fn it_adds_trailing_separators() { - assert_eq!(add_trailing_separator(Cow::Borrowed(&b""[..])), &b""[..]); - assert_eq!(add_trailing_separator(Cow::Borrowed(&b"/"[..])), &b"/"[..]); - assert_eq!(add_trailing_separator(Cow::Borrowed(&b"a"[..])), &b"a/"[..]); - assert_eq!(add_trailing_separator(Cow::Borrowed(&b"a/"[..])), &b"a/"[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b""[..])), &b""[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b"/"[..])), &b"/"[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b"a"[..])), &b"a/"[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b"a/"[..])), &b"a/"[..]); } #[test] fn it_detects_trailing_separators() { - assert!(!has_trailing_separator(b"")); - assert!(!has_trailing_separator(b"/")); - assert!(!has_trailing_separator(b"a")); - assert!(has_trailing_separator(b"a/")); - assert!(has_trailing_separator(b"/a//")); + assert!(!has_trailing_separator(Rules::UNIX, b"")); + assert!(!has_trailing_separator(Rules::UNIX, b"/")); + assert!(!has_trailing_separator(Rules::UNIX, b"a")); + assert!(has_trailing_separator(Rules::UNIX, b"a/")); + assert!(has_trailing_separator(Rules::UNIX, b"/a//")); } #[test] @@ -122,20 +154,39 @@ fn it_conservatively_cleans_the_path() { assert_eq!(cleanpath_conservative_str("/../.././../a"), "/a"); assert_eq!(cleanpath_conservative_str("a/b/../../../../c/../d"), "a/b/../../../../c/../d"); -// Future Windows Support -// -// DOSISH = File::ALT_SEPARATOR != nil -// DOSISH_DRIVE_LETTER = File.dirname("A:") == "A:." -// DOSISH_UNC = File.dirname("//") == "//" -// -// -// if DOSISH -// assert_eq!(cleanpath_conservative, 'c:/foo/bar', 'c:\\foo\\bar') -// end -// -// if DOSISH_UNC -// assert_eq!(cleanpath_conservative, '//', '//') -// else -// assert_eq!(cleanpath_conservative, '/', '//') -// end + assert_eq!(cleanpath_conservative_str("//"), "/"); +} + +#[test] +fn it_conservatively_cleans_windows_paths() { + assert_eq!(windows_cleanpath_conservative("c:\\foo\\bar"), "c:/foo/bar"); + assert_eq!(windows_cleanpath_conservative("//"), "//"); + assert_eq!(windows_cleanpath_conservative("a\\b\\"), "a/b/"); + assert_eq!(windows_cleanpath_conservative("C:/a/../b/"), "C:/a/../b/"); +} + +#[test] +fn it_adds_trailing_separators_on_windows() { + let add = |path: &'static str| add_trailing_separator(Rules::WINDOWS, Cow::Borrowed(path.as_bytes())).into_owned(); + assert_eq!(add("a"), b"a/"); + assert_eq!(add("a\\"), b"a\\"); + assert_eq!(add("C:"), b"C:"); + assert_eq!(add("C:/"), b"C:/"); + assert_eq!(add("//a/b"), b"//a/b/"); + assert!(has_trailing_separator(Rules::WINDOWS, b"a\\")); + assert!(!has_trailing_separator(Rules::UNIX, b"a\\")); +} + +#[test] +fn it_deletes_trailing_separators() { + for &(path, expected) in &[("", ""), ("/", "/"), ("/a", "/a"), ("/a/", "/a"), ("/a//", "/a"), + (".", "."), ("./", "."), (".//", "."), ("///", "/"), ("///a/", "///a")] { + assert_eq!(del_str(Rules::UNIX, path), expected, "{:?}", path); + } + for &(path, expected) in &[("A:", "A:"), ("A:/", "A:/"), ("A://", "A:/"), ("A:.", "A:."), ("A:./", "A:."), + ("A:.//", "A:."), ("//", "//"), ("//a", "//a"), ("//a/", "//a"), ("//a//", "//a"), + ("//a/b", "//a/b"), ("//a/b/", "//a/b"), ("//a/b//", "//a/b"), ("//a/b/c", "//a/b/c"), + ("//a/b/c/", "//a/b/c"), ("//a/b/c//", "//a/b/c"), ("a\\", "a")] { + assert_eq!(del_str(Rules::WINDOWS, path), expected, "{:?}", path); + } } diff --git a/src/dirname.rs b/src/dirname.rs index fe7c3ea..6acae1f 100644 --- a/src/dirname.rs +++ b/src/dirname.rs @@ -1,31 +1,49 @@ -use crate::path_parsing::{find_last_sep_pos, find_last_non_sep_pos, SEP_BYTES}; - -pub fn dirname(path: &[u8]) -> &[u8] { - let mut last_slash_pos = match find_last_sep_pos(path) { - Some(pos) => pos, - _ => return b".", - }; - // Skip trailing slashes. - if last_slash_pos == path.len() - 1 { - let last_non_slash_pos = match find_last_non_sep_pos(&path[..last_slash_pos]) { - Some(pos) => pos, - _ => return SEP_BYTES, - }; - last_slash_pos = match find_last_sep_pos(&path[..last_non_slash_pos]) { - Some(pos) => pos, - _ => return b".", - }; - }; - if let Some(end) = find_last_non_sep_pos(&path[..last_slash_pos]) { - &path[..end + 1] - } else { - SEP_BYTES +use std::borrow::Cow; + +use crate::path_parsing::Rules; + +// Ruby's `File.dirname` +pub fn dirname(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { + let mut name = 0; + let mut root = rules.skip_root(path); + if rules.is_dosish() { + // Keep a UNC prefix + if root > 1 && rules.is_sep(path[0]) { + name = root - 2; + root = name + rules.skip_prefix(&path[name..]); + } + } else if root > 1 { + // Leading separators become one + name = root - 1; + } + let end = rules.last_separator(&path[root..]).map_or(root, |pos| root + pos); + if end == name { + return Cow::Borrowed(b"."); + } + let path = &path[name..]; + let end = end - name; + let root = root - name; + if rules.has_drive_letter(path) { + if path.len() > 2 && rules.is_sep(path[2]) { + // "C:" and one separator, then the rest + let top = 2 + rules.skip_root(&path[2..]); + return Cow::Owned([&path[..3], &path[top..end.max(top)]].concat()); + } + if root == 2 && end == 2 { + return Cow::Owned([&path[..2], b"."].concat()); + } } + Cow::Borrowed(&path[..end]) } #[cfg(test)] -fn dirname_str(path: &str) -> &str { - std::str::from_utf8(dirname(path.as_bytes())).unwrap() +fn dirname_str(path: &str) -> String { + String::from_utf8(dirname(Rules::UNIX, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_dirname(path: &str) -> String { + String::from_utf8(dirname(Rules::WINDOWS, path.as_bytes()).into_owned()).unwrap() } #[test] @@ -76,5 +94,37 @@ fn only_separators() { #[test] fn non_utf8() { - assert_eq!(dirname(b"\xff\xfe/a"), b"\xff\xfe"); + assert_eq!(dirname(Rules::UNIX, b"\xff\xfe/a").as_ref(), b"\xff\xfe"); +} + +#[test] +fn windows_separators() { + assert_eq!(windows_dirname("a\\b"), "a"); + assert_eq!(windows_dirname("a\\b\\"), "a"); + assert_eq!(windows_dirname("a/b\\c"), "a/b"); + assert_eq!(dirname_str("a\\b"), "."); +} + +#[test] +fn windows_drive_letters() { + assert_eq!(windows_dirname("C:"), "C:."); + assert_eq!(windows_dirname("C:a"), "C:."); + assert_eq!(windows_dirname("C:/"), "C:/"); + assert_eq!(windows_dirname("C:/a"), "C:/"); + assert_eq!(windows_dirname("C://a"), "C:/"); + assert_eq!(windows_dirname("C:/a/b"), "C:/a"); + assert_eq!(windows_dirname("C://a//b"), "C:/a"); + assert_eq!(windows_dirname("C:a/b"), "C:a"); + assert_eq!(dirname_str("C:"), "."); +} + +#[test] +fn windows_unc() { + assert_eq!(windows_dirname("//"), "//"); + assert_eq!(windows_dirname("//a"), "//a"); + assert_eq!(windows_dirname("//a/b"), "//a/b"); + assert_eq!(windows_dirname("//a/b/c"), "//a/b"); + assert_eq!(windows_dirname("//a/b/c/d"), "//a/b/c"); + assert_eq!(windows_dirname("///a"), "//a"); + assert_eq!(dirname_str("//a/b"), "/a"); } diff --git a/src/extname.rs b/src/extname.rs index 79b06a8..8d17aa1 100644 --- a/src/extname.rs +++ b/src/extname.rs @@ -1,8 +1,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use crate::path_parsing::{find_last_non_sep_pos, find_last_sep_pos}; +use crate::path_parsing::Rules; -// Ruby 2.7 changed `File.extname("foo.")` from `""` to `"."`. `FasterPath` +// Ruby 2.7 changed `File.extname("foo.")` from `""` to `"."` (except on +// Windows, where trailing dots aren't part of a file name). `FasterPath` // follows the Ruby it is loaded into; see `Init_faster_pathname`. static TRAILING_DOT_IS_EXTNAME: AtomicBool = AtomicBool::new(false); @@ -10,38 +11,77 @@ pub fn set_trailing_dot_is_extname(value: bool) { TRAILING_DOT_IS_EXTNAME.store(value, Ordering::Relaxed); } -pub fn extname(path: &[u8]) -> &[u8] { - extname_with(path, TRAILING_DOT_IS_EXTNAME.load(Ordering::Relaxed)) +// Ruby's `File.extname` +pub fn extname(rules: Rules, path: &[u8]) -> &[u8] { + extname_with(rules, path, TRAILING_DOT_IS_EXTNAME.load(Ordering::Relaxed)) } -// Ruby's `ruby_enc_find_extname` for non-Windows platforms. -fn extname_with(path: &[u8], trailing_dot_is_extname: bool) -> &[u8] { - // The last component, without trailing separators - let end = match find_last_non_sep_pos(path) { - Some(pos) => pos + 1, - None => return b"", +// Ruby's `ruby_enc_find_extname` +fn extname_with(rules: Rules, path: &[u8], trailing_dot_is_extname: bool) -> &[u8] { + let end = path.len(); + // The last component + let mut p = match rules.last_separator(path) { + Some(pos) => { + let mut p = pos; + while p < end && rules.is_sep(path[p]) { + p += 1; + } + p + } + None => 0, }; - let start = find_last_sep_pos(&path[..end]).map_or(0, |pos| pos + 1); - let name = &path[start..end]; + let name = p; // Leading dots don't start an extension - let leading_dots = name.iter().take_while(|&&c| c == b'.').count(); - match name[leading_dots..].iter().rposition(|&c| c == b'.') { - None => b"", - Some(pos) => { - let dot = leading_dots + pos; - if dot + 1 == name.len() && !trailing_dot_is_extname { - b"" + while p < end && path[p] == b'.' { + p += 1; + } + let mut dot = None; + while p < end { + let c = path[p]; + if c == b'.' || (rules.is_dosish() && c == b' ') { + if !rules.is_dosish() { + dot = Some(p); } else { - &name[dot..] + // NTFS ignores trailing dots and spaces + let last = p; + let mut last_dot = last; + p += 1; + while p < end && (path[p] == b'.' || path[p] == b' ') { + if path[p] == b'.' { + last_dot = p; + } + p += 1; + } + if p >= end || rules.is_ads(path[p]) { + p = last; + break; + } + if c == b'.' || last_dot > last { + dot = Some(last_dot); + } + continue; } + } else if rules.is_ads(c) || rules.is_sep(c) { + break; } + p += 1; + } + match dot { + Some(dot) if dot != name && dot + 1 == p => if trailing_dot_is_extname { &path[dot..p] } else { b"" }, + Some(dot) if dot != name => &path[dot..p], + _ => b"", } } #[cfg(test)] fn extname_str(path: &str, trailing_dot_is_extname: bool) -> &str { - std::str::from_utf8(extname_with(path.as_bytes(), trailing_dot_is_extname)).unwrap() + std::str::from_utf8(extname_with(Rules::UNIX, path.as_bytes(), trailing_dot_is_extname)).unwrap() +} + +#[cfg(test)] +fn windows_extname(path: &str) -> &str { + std::str::from_utf8(extname_with(Rules::WINDOWS, path.as_bytes(), true)).unwrap() } #[test] @@ -61,6 +101,8 @@ fn it_finds_the_extension() { assert_eq!(extname_str("..foo", trailing_dot), ""); assert_eq!(extname_str("a/..b", trailing_dot), ""); assert_eq!(extname_str("...a", trailing_dot), ""); + assert_eq!(extname_str("a.rb ", trailing_dot), ".rb "); + assert_eq!(extname_str("a\\b.c", trailing_dot), ".c"); } } @@ -89,5 +131,24 @@ fn trailing_dot() { #[test] fn non_utf8() { - assert_eq!(extname_with(b"\xff.\xfe", false), b".\xfe"); + assert_eq!(extname_with(Rules::UNIX, b"\xff.\xfe", false), b".\xfe"); +} + +#[test] +fn windows() { + assert_eq!(windows_extname("a.rb"), ".rb"); + assert_eq!(windows_extname("a.b\\c"), ""); + assert_eq!(windows_extname("a\\b.c"), ".c"); + assert_eq!(windows_extname("a.rb\\"), ".rb"); + assert_eq!(windows_extname("foo."), ""); + assert_eq!(windows_extname("foo.rb."), ".rb"); + assert_eq!(windows_extname("foo.rb "), ".rb"); + assert_eq!(windows_extname("foo.rb. ."), ".rb"); + assert_eq!(windows_extname("foo.rb::$DATA"), ".rb"); + assert_eq!(windows_extname("foo.rb::$DATA.bar"), ".rb"); + assert_eq!(windows_extname("foo .ext"), ".ext"); + assert_eq!(windows_extname("foo. .ext"), ".ext"); + assert_eq!(windows_extname("foo.ext .ext"), ".ext"); + assert_eq!(windows_extname("foo .ext "), ".ext"); + assert_eq!(windows_extname(".bashrc"), ""); } diff --git a/src/helpers.rs b/src/helpers.rs deleted file mode 100644 index ed6f8a9..0000000 --- a/src/helpers.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[cfg(windows)] -#[inline] -pub fn is_same_path(a: &[u8], b: &[u8]) -> bool { - a.eq_ignore_ascii_case(b) -} - -#[cfg(not(windows))] -#[inline] -pub fn is_same_path(a: &[u8], b: &[u8]) -> bool { - a == b -} diff --git a/src/lib.rs b/src/lib.rs index 345d43d..0bcab22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ #[macro_use] mod ruby; -mod helpers; mod pathname; mod basename; mod chop_basename; diff --git a/src/path_parsing.rs b/src/path_parsing.rs index 065b793..c568358 100644 --- a/src/path_parsing.rs +++ b/src/path_parsing.rs @@ -1,35 +1,262 @@ -use memchr::{memchr, memrchr}; -use std::path::MAIN_SEPARATOR; +// The building blocks of Ruby's path handling (`file.c`), for both of the +// sets of rules Ruby is built with: +// +// * Unix: `/` is the only separator. +// * Windows ("DOSISH"): `/` and `\` are separators, a path can start with a +// drive letter (`C:`) or a UNC prefix (`//server/share`), NTFS ignores +// trailing dots and spaces and `:stream` names at the end of a file name, +// and file names compare case-insensitively. +// +// Both are always compiled so each can be tested on any platform; +// `Rules::NATIVE` is the one Ruby uses where this is built. +use memchr::{memchr, memchr2, memrchr, memrchr2}; -pub const SEP: u8 = MAIN_SEPARATOR as u8; -pub const SEP_BYTES: &[u8] = &[SEP]; - -// Returns the byte offset of the last byte that equals MAIN_SEPARATOR. -#[inline(always)] -pub fn find_last_sep_pos(bytes: &[u8]) -> Option { - memrchr(SEP, bytes) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rules { + dosish: bool, } -// Returns the byte offset of the last byte that is not MAIN_SEPARATOR. -#[inline(always)] -pub fn find_last_non_sep_pos(bytes: &[u8]) -> Option { - bytes.iter().rposition(|&b| b != SEP) +// `File::SEPARATOR`, which joins paths on every platform. +pub const SEP_BYTES: &[u8] = b"/"; + +impl Rules { + #[cfg_attr(not(test), allow(dead_code))] + pub const UNIX: Rules = Rules { dosish: false }; + #[cfg_attr(not(test), allow(dead_code))] + pub const WINDOWS: Rules = Rules { dosish: true }; + pub const NATIVE: Rules = Rules { dosish: cfg!(windows) }; + + #[inline(always)] + pub fn is_dosish(self) -> bool { + self.dosish + } + + // Ruby's `isdirsep` + #[inline(always)] + pub fn is_sep(self, c: u8) -> bool { + c == b'/' || (self.dosish && c == b'\\') + } + + // Byte offset of the last separator. + #[inline] + pub fn last_sep_pos(self, bytes: &[u8]) -> Option { + if self.dosish { memrchr2(b'/', b'\\', bytes) } else { memrchr(b'/', bytes) } + } + + // Byte offset of the last byte that isn't a separator. + #[inline] + pub fn last_non_sep_pos(self, bytes: &[u8]) -> Option { + bytes.iter().rposition(|&c| !self.is_sep(c)) + } + + #[inline] + pub fn contains_sep(self, bytes: &[u8]) -> bool { + if self.dosish { memchr2(b'/', b'\\', bytes).is_some() } else { memchr(b'/', bytes).is_some() } + } + + // `has_drive_letter`: "C:" + #[inline] + pub fn has_drive_letter(self, path: &[u8]) -> bool { + self.dosish && path.len() >= 2 && path[0].is_ascii_alphabetic() && path[1] == b':' + } + + // `istrailinggarbage`: ignored at the end of NTFS file names. + #[inline(always)] + fn is_trailing_garbage(self, c: u8) -> bool { + self.dosish && (c == b'.' || c == b' ') + } + + // `isADS`: starts an NTFS alternate data stream name. + #[inline(always)] + pub fn is_ads(self, c: u8) -> bool { + self.dosish && c == b':' + } + + // `Pathname::SAME_PATHS`, and the comparison `File.basename` uses for + // extensions (`CASEFOLD_FILESYSTEM`). + #[inline] + pub fn same_path(self, a: &[u8], b: &[u8]) -> bool { + if self.dosish { a.eq_ignore_ascii_case(b) } else { a == b } + } + + // `skipprefix`: the end of a UNC ("//server/share") or drive letter prefix. + pub fn skip_prefix(self, path: &[u8]) -> usize { + if !self.dosish { + return 0; + } + let end = path.len(); + if end >= 2 && self.is_sep(path[0]) && self.is_sep(path[1]) { + let mut i = 2; + while i < end && self.is_sep(path[i]) { + i += 1; + } + i = self.next_sep(path, i); + if i + 1 < end && !self.is_sep(path[i + 1]) { + i = self.next_sep(path, i + 1); + } + return i; + } + if self.has_drive_letter(path) { + return 2; + } + 0 + } + + // `skiproot`: past a drive letter and the separators after it. + pub fn skip_root(self, path: &[u8]) -> usize { + let mut i = if self.has_drive_letter(path) { 2 } else { 0 }; + while i < path.len() && self.is_sep(path[i]) { + i += 1; + } + i + } + + // `nextdirsep`: the next separator at or after `from`, or the end. + fn next_sep(self, path: &[u8], from: usize) -> usize { + let rest = &path[from.min(path.len())..]; + let found = if self.dosish { memchr2(b'/', b'\\', rest) } else { memchr(b'/', rest) }; + found.map_or(path.len(), |pos| from + pos) + } + + // `strrdirsep`: where the last run of separators that isn't at the end + // of the path starts. + pub fn last_separator(self, path: &[u8]) -> Option { + let end = self.last_non_sep_pos(path)? + 1; + let pos = self.last_sep_pos(&path[..end])?; + Some(path[..pos].iter().rposition(|&c| !self.is_sep(c)).map_or(0, |p| p + 1)) + } + + // `chompdirsep`: where the separators at the end of the path start, or + // its end. + #[inline] + pub fn chomp_dir_sep(self, path: &[u8]) -> usize { + self.last_non_sep_pos(path).map_or(0, |pos| pos + 1) + } + + // `ntfs_tail`: the end of an NTFS file name without trailing dots, + // spaces, separators or `:stream`. + pub fn ntfs_tail(self, path: &[u8]) -> usize { + let end = path.len(); + let mut i = 0; + while i < end && path[i] == b'.' { + i += 1; + } + while i < end && !self.is_ads(path[i]) { + if self.is_trailing_garbage(path[i]) { + let last = i; + i += 1; + while i < end && self.is_trailing_garbage(path[i]) { + i += 1; + } + if i >= end || self.is_ads(path[i]) { + return last; + } + } else if self.is_sep(path[i]) { + let last = i; + i += 1; + while i < end && self.is_sep(path[i]) { + i += 1; + } + if i >= end { + return last; + } + if self.is_ads(path[i]) { + i += 1; + } + } else { + i += 1; + } + } + i + } + + // `File.join(a, b)` + pub fn join(self, a: &[u8], b: &[u8]) -> Vec { + let tail = self.chomp_dir_sep(a); + if b.first().map_or(false, |&c| self.is_sep(c)) { + [&a[..tail], b].concat() + } else if tail == a.len() { + [a, SEP_BYTES, b].concat() + } else { + [a, b].concat() + } + } } -// Whether the given byte sequence contains a MAIN_SEPARATOR. -#[inline(always)] -pub fn contains_sep(bytes: &[u8]) -> bool { - memchr(SEP, bytes).is_some() +// Where `part`, a slice of `whole`, starts in it. +#[inline] +pub fn offset_in(whole: &[u8], part: &[u8]) -> usize { + part.as_ptr() as usize - whole.as_ptr() as usize } -#[test] -fn it_finds_separators() { - assert_eq!(find_last_sep_pos(b""), None); - assert_eq!(find_last_sep_pos(b"a/b/c"), Some(3)); - assert_eq!(find_last_non_sep_pos(b""), None); - assert_eq!(find_last_non_sep_pos(b"///"), None); - assert_eq!(find_last_non_sep_pos(b"a///"), Some(0)); - assert_eq!(find_last_non_sep_pos(b"a/b/c/d/e/f/g/h/i/j/k//////////////////////"), Some(20)); - assert!(contains_sep(b"a/b")); - assert!(!contains_sep(b"ab")); +#[cfg(test)] +mod tests { + use super::*; + + const U: Rules = Rules::UNIX; + const W: Rules = Rules::WINDOWS; + + #[test] + fn it_finds_separators() { + assert_eq!(U.last_sep_pos(b""), None); + assert_eq!(U.last_sep_pos(b"a/b/c"), Some(3)); + assert_eq!(U.last_sep_pos(b"a/b\\c"), Some(1)); + assert_eq!(W.last_sep_pos(b"a/b\\c"), Some(3)); + assert_eq!(U.last_non_sep_pos(b"///"), None); + assert_eq!(U.last_non_sep_pos(b"a///"), Some(0)); + assert_eq!(W.last_non_sep_pos(b"a/\\/"), Some(0)); + assert!(U.contains_sep(b"a/b")); + assert!(!U.contains_sep(b"a\\b")); + assert!(W.contains_sep(b"a\\b")); + } + + #[test] + fn it_skips_prefixes() { + assert_eq!(U.skip_prefix(b"//a/b/c"), 0); + assert_eq!(W.skip_prefix(b"//a/b/c"), 5); + assert_eq!(W.skip_prefix(b"\\\\a\\b\\c"), 5); + assert_eq!(W.skip_prefix(b"//a/"), 3); + assert_eq!(W.skip_prefix(b"//a"), 3); + assert_eq!(W.skip_prefix(b"//"), 2); + assert_eq!(W.skip_prefix(b"C:/a"), 2); + assert_eq!(W.skip_prefix(b"/a"), 0); + assert_eq!(W.skip_root(b"C://a"), 4); + assert_eq!(U.skip_root(b"C://a"), 0); + assert_eq!(U.skip_root(b"//a"), 2); + } + + #[test] + fn it_finds_the_last_separator() { + assert_eq!(U.last_separator(b"a//b//"), Some(1)); + assert_eq!(U.last_separator(b"//b"), Some(0)); + assert_eq!(U.last_separator(b"b//"), None); + assert_eq!(W.last_separator(b"a\\/b"), Some(1)); + assert_eq!(U.chomp_dir_sep(b"a//"), 1); + assert_eq!(U.chomp_dir_sep(b"///"), 0); + assert_eq!(U.chomp_dir_sep(b"a"), 1); + } + + #[test] + fn it_finds_ntfs_tails() { + assert_eq!(W.ntfs_tail(b"foo."), 3); + assert_eq!(W.ntfs_tail(b"foo. ."), 3); + assert_eq!(W.ntfs_tail(b"foo::$DATA"), 3); + assert_eq!(W.ntfs_tail(b"foo.bar"), 7); + assert_eq!(W.ntfs_tail(b"..."), 3); + assert_eq!(W.ntfs_tail(b"foo/"), 3); + } + + #[test] + fn it_joins_like_file_join() { + assert_eq!(U.join(b"a", b"b"), b"a/b"); + assert_eq!(U.join(b"a/", b"b"), b"a/b"); + assert_eq!(U.join(b"a//", b"b"), b"a//b"); + assert_eq!(U.join(b"a/", b"/b"), b"a/b"); + assert_eq!(U.join(b"a", b""), b"a/"); + assert_eq!(U.join(b"/", b""), b"/"); + assert_eq!(U.join(b"", b"b"), b"/b"); + assert_eq!(W.join(b"a\\", b"b"), b"a\\b"); + assert_eq!(W.join(b"a", b"\\b"), b"a\\b"); + assert_eq!(U.join(b"a", b"\\b"), b"a/\\b"); + } } diff --git a/src/pathname.rs b/src/pathname.rs index 682f4b8..28e99c7 100644 --- a/src/pathname.rs +++ b/src/pathname.rs @@ -2,17 +2,16 @@ use std::borrow::Cow; use std::ffi::OsStr; use std::fs; use std::io; -use std::path::Path; use rutie::{AnyException, AnyObject, Array, Boolean, Class, NilClass, Object, RString}; use crate::basename; -use crate::chop_basename; +use crate::chop_basename::{self, is_relative}; use crate::cleanpath_aggressive; -use crate::cleanpath_conservative::{self, add_trailing_separator, has_trailing_separator}; +use crate::cleanpath_conservative::{self, add_trailing_separator, del_trailing_separator, has_trailing_separator}; use crate::dirname; use crate::extname; -use crate::path_parsing::{SEP, SEP_BYTES, find_last_non_sep_pos}; +use crate::path_parsing::Rules; use crate::plus; use crate::relative_path_from::{self, RelativePathError}; use crate::ruby::{ @@ -20,6 +19,9 @@ use crate::ruby::{ truthy_argument, EncodingId, EncodingOf, PathArgument, PathString, RubyResult, }; +// Ruby's path rules on this platform +const RULES: Rules = Rules::NATIVE; + pub fn pn_add_trailing_separator(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; @@ -27,7 +29,7 @@ pub fn pn_add_trailing_separator(arguments: &[AnyObject]) -> RubyResult { return Err(implicit_conversion_error(arguments.first())); } let path = argument.path()?; - match (add_trailing_separator(Cow::Borrowed(path.bytes())), argument.string()) { + match (add_trailing_separator(RULES, Cow::Borrowed(path.bytes())), argument.string()) { (Cow::Borrowed(_), Some(string)) => Ok(string.to_any_object()), (result, _) => Ok(path.to_ruby(&result).into()), } @@ -37,7 +39,7 @@ pub fn pn_is_absolute(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - Ok(Boolean::new(path.bytes().first() == Some(&SEP)).into()) + Ok(Boolean::new(!is_relative(RULES, path.bytes())).into()) } pub fn pn_basename(arguments: &[AnyObject]) -> RubyResult { @@ -46,10 +48,16 @@ pub fn pn_basename(arguments: &[AnyObject]) -> RubyResult { let path = argument.path()?; let ext_argument = PathArgument::new(arguments, 1)?; let ext = ext_argument.path()?; - let basename = basename::last_component(path.bytes()); - let end = basename::ext_end(basename, ext.bytes()); - // Like Ruby, only remove an extension that starts a character. - let result = if path.is_char_boundary(basename, end) { &basename[..end] } else { basename }; + let (component, base_len) = basename::last_component(RULES, path.bytes()); + let result = match base_len { + // Like Ruby, ignore an extension in an incompatible encoding, and only + // remove one that starts a character. + Some(base_len) if EncodingOf::new(&path).merge(&ext).is_ok() => { + let end = basename::ext_end(RULES, component, base_len, ext.bytes()); + if path.is_char_boundary(component, end) { &component[..end] } else { component } + } + _ => component, + }; Ok(path.to_ruby(result).into()) } @@ -79,15 +87,15 @@ fn children(path: &PathString, with_directory: bool, error_path: &AnyObject, let mut array = Array::with_capacity(entries.size_hint().0); for entry in entries { let entry = entry.map_err(|e| system_call_error(&e, error_path, "dir_read"))?; - let file_name = entry.file_name(); - let name = os_bytes(&file_name); + let os_name = entry.file_name(); + let (name, name_encoding) = file_name(&os_name, filesystem); let string = if with_directory { - // Encoded like Ruby's `path + name`, or as the file name if they don't mix. - let encoding = EncodingOf::new(path).merge_with(EncodingOf::of_bytes(&name, filesystem)). - map_or(filesystem, |encoding| encoding.encoding); - encoding.new_string(&os_bytes(entry.path().as_os_str())) + // `File.join(path, name)`, encoded like that, or as the file name if they don't mix. + let encoding = EncodingOf::new(path).merge_with(EncodingOf::of_bytes(&name, name_encoding)). + map_or(name_encoding, |encoding| encoding.encoding); + encoding.new_string(&RULES.join(path.bytes(), &name)) } else { - filesystem.new_string(&name) + name_encoding.new_string(&name) }; array.push(wrap(string)?); } @@ -98,7 +106,7 @@ pub fn pn_chop_basename(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - match chop_basename::chop_basename(path.bytes()) { + match chop_basename::chop_basename(RULES, path.bytes()) { Some((dirname, basename)) => { let mut array = Array::with_capacity(2); array.push(path.to_ruby(dirname)); @@ -113,31 +121,23 @@ pub fn pn_cleanpath_aggressive(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - Ok(path.to_ruby(&cleanpath_aggressive::cleanpath_aggressive(path.bytes())).into()) + Ok(path.to_ruby(&cleanpath_aggressive::cleanpath_aggressive(RULES, path.bytes())).into()) } pub fn pn_cleanpath_conservative(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - Ok(path.to_ruby(&cleanpath_conservative::cleanpath_conservative(path.bytes())).into()) + Ok(path.to_ruby(&cleanpath_conservative::cleanpath_conservative(RULES, path.bytes())).into()) } pub fn pn_del_trailing_separator(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - let bytes = path.bytes(); - if bytes.is_empty() { - return Ok(path.to_ruby(b"").into()); - } - let pos = match find_last_non_sep_pos(bytes) { - Some(pos) => pos, - None => return Ok(path.to_ruby(SEP_BYTES).into()), - }; - match argument.string() { - Some(string) if pos == bytes.len() - 1 => Ok(string.to_any_object()), - _ => Ok(path.to_ruby(&bytes[..pos + 1]).into()), + match (del_trailing_separator(RULES, path.bytes()), argument.string()) { + (Cow::Borrowed(result), Some(string)) if result.len() == path.bytes().len() => Ok(string.to_any_object()), + (result, _) => Ok(path.to_ruby(&result).into()), } } @@ -145,7 +145,7 @@ pub fn pn_is_directory(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - let is_directory = os_path(path.bytes()).map_or(false, |os_path| os_path.is_dir()); + let is_directory = path.os_path().map_or(false, |os_path| os_path.is_dir()); Ok(Boolean::new(is_directory).into()) } @@ -153,7 +153,7 @@ pub fn pn_dirname(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - Ok(path.to_ruby(dirname::dirname(path.bytes())).into()) + Ok(path.to_ruby(&dirname::dirname(RULES, path.bytes())).into()) } // Returns an array of `String`s @@ -184,7 +184,9 @@ fn entries(path: &PathString, error_path: &AnyObject, mut wrap: F) -> RubyRes array.push(wrap(filesystem.new_string(b".."))?); for file in files { let file = file.map_err(|e| system_call_error(&e, error_path, "dir_read"))?; - array.push(wrap(filesystem.new_string(&os_bytes(&file.file_name())))?); + let os_name = file.file_name(); + let (name, encoding) = file_name(&os_name, filesystem); + array.push(wrap(encoding.new_string(&name))?); } Ok(array.into()) } @@ -193,14 +195,14 @@ pub fn pn_extname(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - Ok(path.to_ruby(extname::extname(path.bytes())).into()) + Ok(path.to_ruby(extname::extname(RULES, path.bytes())).into()) } pub fn pn_has_trailing_separator(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - Ok(Boolean::new(has_trailing_separator(path.bytes())).into()) + Ok(Boolean::new(has_trailing_separator(RULES, path.bytes())).into()) } pub fn pn_join(arguments: &[AnyObject]) -> RubyResult { @@ -230,10 +232,10 @@ pub fn pn_join(arguments: &[AnyObject]) -> RubyResult { None => Cow::Borrowed(b""), }; for part in parts { - if result.first() == Some(&SEP) { + if !is_relative(RULES, &result) { break; } - result = Cow::Owned(plus::plus_paths(part, &result).into_owned()); + result = Cow::Owned(plus::plus_paths(RULES, part, &result).into_owned()); } let encoding = match encoding { Some(encoding) => encoding.encoding, @@ -249,7 +251,7 @@ pub fn pn_plus(arguments: &[AnyObject]) -> RubyResult { let argument2 = PathArgument::new(arguments, 1)?; let path2 = argument2.path()?; let encoding = EncodingOf::new(&path1).merge(&path2)?.encoding; - Ok(encoding.new_string(&plus::plus_paths(path1.bytes(), path2.bytes())).into()) + Ok(encoding.new_string(&plus::plus_paths(RULES, path1.bytes(), path2.bytes())).into()) } pub fn pn_is_relative(arguments: &[AnyObject]) -> RubyResult { @@ -259,7 +261,7 @@ pub fn pn_is_relative(arguments: &[AnyObject]) -> RubyResult { return Ok(Boolean::new(false).into()); } let path = argument.path()?; - Ok(Boolean::new(path.bytes().first() != Some(&SEP)).into()) + Ok(Boolean::new(is_relative(RULES, path.bytes())).into()) } pub fn pn_relative_path_from(arguments: &[AnyObject]) -> RubyResult { @@ -278,7 +280,7 @@ pub fn pn_relative_path_from(arguments: &[AnyObject]) -> RubyResult { let encoding = EncodingOf::new(&dest).merge(&base)?.encoding; let inspect = |bytes: &[u8]| crate::ruby::inspect(&encoding.new_string(bytes)); - match relative_path_from::relative_path_from(dest.bytes(), base.bytes()) { + match relative_path_from::relative_path_from(RULES, dest.bytes(), base.bytes()) { Ok(path) => new_pathname(&pathname, encoding.new_string(&path)), Err(RelativePathError::DifferentPrefix(dest_prefix, base_directory)) => { let message = format!("different prefix: {} and {}", inspect(&dest_prefix)?, inspect(&base_directory)?); @@ -299,8 +301,8 @@ fn error_path(argument: &PathArgument, default: &str) -> AnyObject { } fn read_dir(path: &PathString, error_path: &AnyObject) -> Result { - let os_path = os_path(path.bytes()).map_err(|e| system_call_error(&e, error_path, "dir_initialize"))?; - fs::read_dir(os_path).map_err(|e| system_call_error(&e, error_path, "dir_initialize")) + let os_path = path.os_path().map_err(|e| system_call_error(&e, error_path, "dir_initialize"))?; + fs::read_dir(&*os_path).map_err(|e| system_call_error(&e, error_path, "dir_initialize")) } // `SystemCallError.new(path, errno, func)` gives the same `Errno::*` error @@ -324,29 +326,26 @@ fn system_call_error(err: &io::Error, path: &AnyObject, func: &str) -> AnyExcept AnyException::from_io_error(err, &path) } +// A file name from the OS as Ruby's `Dir` methods return it, in the +// filesystem encoding: its bytes, and their encoding. #[cfg(unix)] -fn os_path(bytes: &[u8]) -> io::Result<&Path> { - use std::os::unix::ffi::OsStrExt; - Ok(Path::new(OsStr::from_bytes(bytes))) -} - -#[cfg(not(unix))] -fn os_path(bytes: &[u8]) -> io::Result<&Path> { - std::str::from_utf8(bytes).map(Path::new). - map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path name is not valid UTF-8")) -} - -#[cfg(unix)] -fn os_bytes(string: &OsStr) -> Cow<'_, [u8]> { +fn file_name(name: &OsStr, filesystem: EncodingId) -> (Cow<'_, [u8]>, EncodingId) { use std::os::unix::ffi::OsStrExt; - Cow::Borrowed(string.as_bytes()) + (Cow::Borrowed(name.as_bytes()), filesystem) } #[cfg(not(unix))] -fn os_bytes(string: &OsStr) -> Cow<'_, [u8]> { - match string.to_string_lossy() { - Cow::Borrowed(string) => Cow::Borrowed(string.as_bytes()), - Cow::Owned(string) => Cow::Owned(string.into_bytes()), +fn file_name(name: &OsStr, filesystem: EncodingId) -> (Cow<'_, [u8]>, EncodingId) { + let utf8 = match name.to_string_lossy() { + Cow::Borrowed(name) => Cow::Borrowed(name.as_bytes()), + Cow::Owned(name) => Cow::Owned(name.into_bytes()), + }; + if utf8.is_ascii() || filesystem == EncodingId::utf8() { + return (utf8, filesystem); + } + match EncodingId::utf8().transcode(&utf8, filesystem) { + Some(encoded) => (Cow::Owned(encoded), filesystem), + None => (utf8, EncodingId::utf8()), } } diff --git a/src/plus.rs b/src/plus.rs index 93bfd01..7007982 100644 --- a/src/plus.rs +++ b/src/plus.rs @@ -1,76 +1,80 @@ use std::borrow::Cow; +use crate::basename::basename; use crate::chop_basename::chop_basename; -use crate::path_parsing::SEP; +use crate::dirname::dirname; +use crate::path_parsing::Rules; -pub fn plus_paths<'a>(path1: &'a [u8], path2: &[u8]) -> Cow<'a, [u8]> { +// Pathname's `plus`, which `Pathname#+` and `Pathname#join` use. +pub fn plus_paths<'a>(rules: Rules, path1: &'a [u8], path2: &[u8]) -> Cow<'a, [u8]> { + // The names in path2, and where each starts let mut prefix2 = path2; let mut index_list2: Vec = vec![]; let mut basename_list2: Vec<&[u8]> = vec![]; - while let Some((pfx2, basename2)) = chop_basename(prefix2) { - prefix2 = pfx2; - index_list2.push(pfx2.len()); - basename_list2.push(basename2); + while let Some((prefix, basename)) = chop_basename(rules, prefix2) { + prefix2 = prefix; + index_list2.push(prefix.len()); + basename_list2.push(basename); } if !prefix2.is_empty() { - return path2.to_vec().into(); - }; + return Cow::Owned(path2.to_vec()); + } + index_list2.reverse(); + basename_list2.reverse(); + // The names before `first` have been shifted off + let mut first = 0; - let result_prefix: Cow<'a, [u8]>; let mut prefix1 = path1; loop { - let mut new_len = basename_list2.len() - count_trailing(b".", &basename_list2); - index_list2.truncate(new_len); - basename_list2.truncate(new_len); - match chop_basename(prefix1) { - None => { - result_prefix = prefix1.into(); - break; - } - Some((pfx1, basename1)) => { - prefix1 = pfx1; - if basename1 == b"." { continue; }; - if basename1 == b".." || basename_list2.last() != Some(&&b".."[..]) { - result_prefix = [prefix1, basename1].concat().into(); - break; - } - } + while basename_list2.get(first) == Some(&&b"."[..]) { + first += 1; + } + let (prefix, basename1) = match chop_basename(rules, prefix1) { + Some(chopped) => chopped, + None => break, + }; + prefix1 = prefix; + if basename1 == b"." { + continue; } - if new_len > 0 { - new_len -= 1; - index_list2.truncate(new_len); - basename_list2.truncate(new_len); + if basename1 == b".." || basename_list2.get(first) != Some(&&b".."[..]) { + // `prefix1 + basename1`; the basename follows its prefix + prefix1 = &path1[..prefix1.len() + basename1.len()]; + break; } + first += 1; } - if !result_prefix.is_empty() && result_prefix.iter().all(|&b| b == SEP) { - let new_len = basename_list2.len() - count_trailing(b"..", &basename_list2); - index_list2.truncate(new_len); - basename_list2.truncate(new_len); + let mut prefix1_has_name = chop_basename(rules, prefix1).is_some(); + if !prefix1_has_name && rules.contains_sep(basename(rules, prefix1, b"")) { + // Nothing goes above the root + prefix1_has_name = true; + while basename_list2.get(first) == Some(&&b".."[..]) { + first += 1; + } } - if let Some(&last_index2) = index_list2.last() { - let suffix = &path2[last_index2..]; - match (result_prefix.last(), suffix.first()) { - (Some(&SEP), Some(&SEP)) => [&result_prefix[..], &suffix[1..]].concat().into(), - (Some(&SEP), Some(_)) | (Some(_), Some(&SEP)) => [&result_prefix[..], suffix].concat().into(), - (None, Some(_)) => suffix.to_vec().into(), - _ => [&result_prefix[..], &[SEP], suffix].concat().into(), + if first < basename_list2.len() { + let suffix2 = &path2[index_list2[first]..]; + if prefix1_has_name { + Cow::Owned(rules.join(prefix1, suffix2)) + } else { + Cow::Owned([prefix1, suffix2].concat()) } - } else if result_prefix.is_empty() { - Cow::Borrowed(b".") + } else if prefix1_has_name { + Cow::Borrowed(prefix1) } else { - result_prefix + dirname(rules, prefix1) } } -#[inline(always)] -fn count_trailing(x: &[u8], xs: &[&[u8]]) -> usize { - xs.iter().rev().take_while(|&&c| c == x).count() +#[cfg(test)] +fn plus_str(path1: &str, path2: &str) -> String { + String::from_utf8(plus_paths(Rules::UNIX, path1.as_bytes(), path2.as_bytes()).into_owned()).unwrap() } #[cfg(test)] -fn plus_str(path1: &str, path2: &str) -> String { - String::from_utf8(plus_paths(path1.as_bytes(), path2.as_bytes()).into_owned()).unwrap() +fn windows_plus(path1: &str, path2: &str) -> String { + String::from_utf8(plus_paths(Rules::WINDOWS, path1.as_bytes(), path2.as_bytes()).into_owned()).unwrap() } #[test] @@ -96,3 +100,19 @@ fn it_will_plus_same_as_ruby() { assert_eq!("//foo/var/bar", plus_str("//foo/var", "bar")); } + +#[test] +fn it_will_plus_same_as_ruby_on_windows() { + assert_eq!("a/b", windows_plus("a", "b")); + assert_eq!("a/b", windows_plus("a\\", "b")); + assert_eq!("/b", windows_plus("a", "/b")); + assert_eq!("\\b", windows_plus("a", "\\b")); + assert_eq!("C:/b", windows_plus("a", "C:/b")); + assert_eq!("C:/a/b", windows_plus("C:/a", "b")); + assert_eq!("C:/b", windows_plus("C:/a", "../b")); + assert_eq!("C:/b", windows_plus("C:/", "../b")); + assert_eq!("C:\\a/b", windows_plus("C:\\a", "b")); + assert_eq!("a", windows_plus("a\\b", "..")); + assert_eq!("//a/b/c", windows_plus("//a/b", "c")); + assert_eq!("//a/b/c", windows_plus("//a/b", "../c")); +} diff --git a/src/prepend_prefix.rs b/src/prepend_prefix.rs index ae8b536..6636483 100644 --- a/src/prepend_prefix.rs +++ b/src/prepend_prefix.rs @@ -1,18 +1,17 @@ use std::borrow::Cow; + +use crate::cleanpath_conservative::add_trailing_separator; use crate::dirname::dirname; -use crate::path_parsing::{SEP, contains_sep}; +use crate::path_parsing::Rules; -pub fn prepend_prefix<'a>(prefix: &'a [u8], relpath: &[u8]) -> Cow<'a, [u8]> { +// Pathname's `prepend_prefix` +pub fn prepend_prefix<'a>(rules: Rules, prefix: &'a [u8], relpath: &[u8]) -> Cow<'a, [u8]> { if relpath.is_empty() { - dirname(prefix).into() - } else if contains_sep(prefix) { - let prefix_dirname = dirname(prefix); - match prefix_dirname.last() { - None => relpath.to_vec().into(), - Some(&SEP) => [prefix_dirname, relpath].concat().into(), - _ => [prefix_dirname, &[SEP], relpath].concat().into(), - } + dirname(rules, prefix) + } else if rules.contains_sep(prefix) { + let prefix = add_trailing_separator(rules, dirname(rules, prefix)); + Cow::Owned([&prefix[..], relpath].concat()) } else { - [prefix, relpath].concat().into() + Cow::Owned([prefix, relpath].concat()) } } diff --git a/src/relative_path_from.rs b/src/relative_path_from.rs index b3a619b..2b40c96 100644 --- a/src/relative_path_from.rs +++ b/src/relative_path_from.rs @@ -1,7 +1,6 @@ -use crate::helpers::is_same_path; -use crate::path_parsing::SEP_BYTES; -use crate::cleanpath_aggressive::cleanpath_aggressive; use crate::chop_basename::chop_basename; +use crate::cleanpath_aggressive::cleanpath_aggressive; +use crate::path_parsing::{Rules, SEP_BYTES}; #[derive(Debug, PartialEq)] pub enum RelativePathError { @@ -11,21 +10,22 @@ pub enum RelativePathError { BaseDirectoryHasDotDot(Vec), } -pub fn relative_path_from(dest: &[u8], base: &[u8]) -> Result, RelativePathError> { - let dest_directory = cleanpath_aggressive(dest); - let base_directory = cleanpath_aggressive(base); +// Pathname's `relative_path_from` +pub fn relative_path_from(rules: Rules, dest: &[u8], base: &[u8]) -> Result, RelativePathError> { + let dest_directory = cleanpath_aggressive(rules, dest); + let base_directory = cleanpath_aggressive(rules, base); - let (dest_prefix, mut dest_names) = to_names(&dest_directory); - let (base_prefix, mut base_names) = to_names(&base_directory); + let (dest_prefix, mut dest_names) = to_names(rules, &dest_directory); + let (base_prefix, mut base_names) = to_names(rules, &base_directory); - if !is_same_path(dest_prefix, base_prefix) { + if !rules.same_path(dest_prefix, base_prefix) { return Err(RelativePathError::DifferentPrefix(dest_prefix.to_vec(), base_directory.to_vec())); } // Remove the shared leading names (stored last, as the names are collected in reverse) { let num_same = dest_names.iter().rev().zip(base_names.iter().rev()). - take_while(|&(dest, base)| is_same_path(dest, base)).count(); + take_while(|&(dest, base)| rules.same_path(dest, base)).count(); dest_names.truncate(dest_names.len() - num_same); base_names.truncate(base_names.len() - num_same); }; @@ -44,10 +44,10 @@ pub fn relative_path_from(dest: &[u8], base: &[u8]) -> Result, RelativeP } #[inline(always)] -fn to_names(path: &[u8]) -> (&[u8], Vec<&[u8]>) { +fn to_names(rules: Rules, path: &[u8]) -> (&[u8], Vec<&[u8]>) { let mut result: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((dest, basename)) = chop_basename(prefix) { + while let Some((dest, basename)) = chop_basename(rules, prefix) { prefix = dest; if basename != b"." { result.push(basename); @@ -57,27 +57,38 @@ fn to_names(path: &[u8]) -> (&[u8], Vec<&[u8]>) { } #[cfg(test)] -fn relative_str(dest: &str, base: &str) -> Option { - relative_path_from(dest.as_bytes(), base.as_bytes()).ok().map(|path| String::from_utf8(path).unwrap()) +fn relative_str(rules: Rules, dest: &str, base: &str) -> Option { + relative_path_from(rules, dest.as_bytes(), base.as_bytes()).ok().map(|path| String::from_utf8(path).unwrap()) } #[test] fn it_finds_relative_paths() { - assert_eq!(relative_str("a", "b").unwrap(), "../a"); - assert_eq!(relative_str("/a/b/c/d", "/a/b").unwrap(), "c/d"); - assert_eq!(relative_str("/a/b", "/a/b/c/d").unwrap(), "../.."); - assert_eq!(relative_str(".", ".").unwrap(), "."); - assert_eq!(relative_str("a/b/c", "a/d").unwrap(), "../b/c"); + let u = Rules::UNIX; + assert_eq!(relative_str(u, "a", "b").unwrap(), "../a"); + assert_eq!(relative_str(u, "/a/b/c/d", "/a/b").unwrap(), "c/d"); + assert_eq!(relative_str(u, "/a/b", "/a/b/c/d").unwrap(), "../.."); + assert_eq!(relative_str(u, ".", ".").unwrap(), "."); + assert_eq!(relative_str(u, "a/b/c", "a/d").unwrap(), "../b/c"); } #[test] fn it_rejects_incompatible_paths() { assert_eq!( - relative_path_from(b"/", b"."), + relative_path_from(Rules::UNIX, b"/", b"."), Err(RelativePathError::DifferentPrefix(b"/".to_vec(), b".".to_vec())) ); assert_eq!( - relative_path_from(b"a", b".."), + relative_path_from(Rules::UNIX, b"a", b".."), Err(RelativePathError::BaseDirectoryHasDotDot(b"..".to_vec())) ); } + +#[test] +fn it_finds_relative_paths_on_windows() { + let w = Rules::WINDOWS; + assert_eq!(relative_str(w, "C:\\a\\b", "c:/a").unwrap(), "b"); + assert_eq!(relative_str(w, "C:/A/b", "c:/a/c").unwrap(), "../b"); + assert_eq!(relative_str(w, "//a/b/c/d", "//a/b/c").unwrap(), "d"); + assert_eq!(relative_str(w, "C:/a", "D:/a"), None); + assert_eq!(relative_str(Rules::UNIX, "a/B", "a/b").unwrap(), "../B"); +} diff --git a/src/ruby.rs b/src/ruby.rs index a80536d..b6f467b 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -11,6 +11,8 @@ // * Strings are read as bytes in their own encoding, never assumed to be UTF-8. use std::any::Any; use std::borrow::Cow; +use std::io; +use std::path::Path; use std::sync::atomic::{AtomicI32, AtomicU8, Ordering}; use std::thread; @@ -18,6 +20,8 @@ use rutie::rubysys; use rutie::types::{c_char, c_long, Argc, EncodingIndex, Value, ValueType}; use rutie::{AnyException, AnyObject, Class, Encoding, NilClass, Object, RString, Symbol, VM}; +use crate::path_parsing::Rules; + pub type RubyResult = Result; // Defines Ruby methods taking any number of arguments (arity -1). The body @@ -171,7 +175,11 @@ impl PathArgument { /// A path read from a Ruby `String`: its bytes and its encoding. pub struct PathString<'a> { - bytes: Cow<'a, [u8]>, + original: Cow<'a, [u8]>, + // On Windows, a copy in which each `\` that is part of a multibyte + // character is a NUL instead, so it isn't taken for a separator; see + // `EncodingId::mask_backslashes`. + masked: Option>, pub encoding: EncodingId, } @@ -182,7 +190,7 @@ impl<'a> PathString<'a> { } pub fn literal(bytes: &'static [u8]) -> Self { - PathString { bytes: Cow::Borrowed(bytes), encoding: EncodingId::us_ascii() } + PathString { original: Cow::Borrowed(bytes), masked: None, encoding: EncodingId::us_ascii() } } /// Reads `string`, which must be kept alive (and unmodified) while the @@ -204,15 +212,17 @@ impl<'a> PathString<'a> { if bytes.contains(&0) { return Err(argument_error("path name contains null byte")); } - Ok(PathString { bytes: Cow::Borrowed(bytes), encoding }) + let masked = if Rules::NATIVE.is_dosish() { encoding.mask_backslashes(bytes) } else { None }; + Ok(PathString { original: Cow::Borrowed(bytes), masked, encoding }) } pub fn into_owned(self) -> PathString<'static> { - PathString { bytes: Cow::Owned(self.bytes.into_owned()), encoding: self.encoding } + PathString { original: Cow::Owned(self.original.into_owned()), masked: self.masked, encoding: self.encoding } } + /// The bytes the path functions work on. pub fn bytes(&self) -> &[u8] { - &self.bytes + self.masked.as_deref().unwrap_or(&self.original) } /// A new Ruby string with `bytes` in this path's encoding. @@ -230,23 +240,45 @@ impl<'a> PathString<'a> { // Not a continuation byte return (bytes[pos] & 0xC0) != 0x80; } - if self.encoding == EncodingId::us_ascii() || self.encoding == EncodingId::ascii_8bit() { + if self.encoding.is_single_byte_or_utf8() { return true; } // Other encodings: walk the characters from the start like Ruby does. - // Safety: the encoding comes from a string or `Encoding`, so its index - // is valid, and every pointer passed to `rb_enc_mbclen` is within - // `bytes`, with `end` one past its last byte. - let encoding = unsafe { rubysys::encoding::rb_enc_from_index(self.encoding.0) }; - let end = bytes.as_ptr_range().end as *const c_char; + let bytes = unmask(bytes); let mut index = 0; while index < pos { - let start = bytes[index..].as_ptr() as *const c_char; - let length = unsafe { rubysys::encoding::rb_enc_mbclen(start, end, encoding) }; - index += length.max(1) as usize; + index += self.encoding.char_len(&bytes, index); } index == pos } + + /// The path to give the OS. + #[cfg(unix)] + pub fn os_path(&self) -> io::Result> { + use std::os::unix::ffi::OsStrExt; + Ok(Cow::Borrowed(Path::new(std::ffi::OsStr::from_bytes(&self.original)))) + } + + /// The path to give the OS, which takes Unicode on Windows. + #[cfg(not(unix))] + pub fn os_path(&self) -> io::Result> { + let invalid = || io::Error::new(io::ErrorKind::InvalidInput, "path name can't be converted to UTF-8"); + if self.encoding == EncodingId::utf8() || self.original.is_ascii() { + return std::str::from_utf8(&self.original).map(|path| Cow::Borrowed(Path::new(path))).map_err(|_| invalid()); + } + let utf8 = self.encoding.transcode(&self.original, EncodingId::utf8()).ok_or_else(invalid)?; + String::from_utf8(utf8).map(|path| Cow::Owned(path.into())).map_err(|_| invalid()) + } +} + +// Puts back the `\`s `EncodingId::mask_backslashes` replaced. NUL is never +// in a path otherwise. +fn unmask(bytes: &[u8]) -> Cow<'_, [u8]> { + if Rules::NATIVE.is_dosish() && memchr::memchr(0, bytes).is_some() { + Cow::Owned(bytes.iter().map(|&c| if c == 0 { b'\\' } else { c }).collect()) + } else { + Cow::Borrowed(bytes) + } } /// An encoding, by its index in Ruby's table of encodings. @@ -333,8 +365,62 @@ impl EncodingId { } } + // UTF-8, or an encoding of single bytes where every character starts a + // character. + fn is_single_byte_or_utf8(self) -> bool { + self == EncodingId::utf8() || self == EncodingId::us_ascii() || self == EncodingId::ascii_8bit() + } + + // The length of the character at `start` (`rb_enc_mbclen`), at least 1. + fn char_len(self, bytes: &[u8], start: usize) -> usize { + // Safety: the index is valid (see `is_char_boundary`), and both + // pointers are within `bytes`, `end` one past its last byte. + let length = unsafe { + rubysys::encoding::rb_enc_mbclen( + bytes[start..].as_ptr() as *const c_char, + bytes.as_ptr_range().end as *const c_char, + rubysys::encoding::rb_enc_from_index(self.0), + ) + }; + (length.max(1) as usize).min(bytes.len() - start) + } + + /// In encodings such as Shift_JIS, `\` (0x5C) can be the second byte of a + /// character. Windows Ruby only takes a `\` that starts a character for a + /// separator; this returns a copy of `bytes` with the others replaced by + /// NUL (which a path never contains), or `None` if there are none. + pub fn mask_backslashes(self, bytes: &[u8]) -> Option> { + if self.is_single_byte_or_utf8() || !bytes.contains(&b'\\') { + return None; + } + let mut masked: Option> = None; + let mut index = 0; + while index < bytes.len() { + let length = self.char_len(bytes, index); + for trailing in index + 1..index + length { + if bytes[trailing] == b'\\' { + masked.get_or_insert_with(|| bytes.to_vec())[trailing] = 0; + } + } + index += length; + } + masked + } + + /// `bytes`, in this encoding, converted to the encoding `to` + /// (`String#encode`), or `None` if they can't be. + #[cfg_attr(unix, allow(dead_code))] + pub fn transcode(self, bytes: &[u8], to: EncodingId) -> Option> { + let string = self.new_string(bytes); + let encoded = string.protect_send("encode", &[to.to_encoding().into()]).ok()?; + // Copied before anything else can run the garbage collector + let encoded = encoded.try_convert_to::().ok()?; + Some(encoded.to_bytes_unchecked().to_vec()) + } + /// A new `String` of `bytes` in this encoding. pub fn new_string(self, bytes: &[u8]) -> RString { + let bytes = unmask(bytes); // Safety: `bytes` is valid for its length, which Ruby copies, and the // index is valid (see `is_char_boundary`). RString::from(unsafe { diff --git a/test/test_helper.rb b/test/test_helper.rb index b9c3bfe..5b66016 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -34,8 +34,13 @@ def assert_incompatible_encoding end ::Minitest::Test.class_eval do - # Ruby 2.7 changed File.extname("foo.") from "" to "." - TRAILING_DOT_EXTNAME = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7") ? "." : "" + # Ruby 2.7 changed File.extname("foo.") from "" to ".", except on Windows + TRAILING_DOT_EXTNAME = + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7") && /mingw|mswin/ !~ RUBY_PLATFORM + "." + else + "" + end DRIVE = Dir.pwd[%r{\A(?:[a-z]:|//[^/]+/[^/]+)}i] POSIX = /cygwin|mswin|bccwin|mingw|emx/ !~ RUBY_PLATFORM NTFS = !(/mingw|mswin|bccwin/ !~ RUBY_PLATFORM) From 916c821c77b7618c966eda513ed47d9294e98e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:51:48 +0000 Subject: [PATCH 05/11] Match Ruby's Windows tests and `directory?` Windows CI ran FasterPath's Windows-only tests for the first time. Two had their expectations wrong, and now match the same cases in Ruby's test_pathname.rb: - `add_trailing_separator('A:')` is "A:" (`File.basename("A:a")` is "a"), not "A:/". - The `cleanpath_conservative` case had its input and output swapped: 'c:\foo\bar' cleans to 'c:/foo/bar'. `directory?` now returns false for a path whose first "..." is a whole name of dots, such as "dir/...", as Ruby's Windows `stat` does (`check_valid_dir` in win32.c); Windows itself takes it for "dir". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- src/path_parsing.rs | 28 ++++++++++++++++++++++++++++ src/pathname.rs | 4 +++- test/add_trailing_separator_test.rb | 13 +++++++------ test/cleanpath_conservative_test.rb | 3 ++- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/path_parsing.rs b/src/path_parsing.rs index c568358..517055b 100644 --- a/src/path_parsing.rs +++ b/src/path_parsing.rs @@ -170,6 +170,22 @@ impl Rules { i } + // Ruby's Windows `stat` doesn't find a path whose first "..." is a + // whole name of dots, as Windows would take "dir\\..." for "dir" + // (`check_valid_dir` in `win32.c`). + pub fn has_dots_name(self, path: &[u8]) -> bool { + if !self.dosish { + return false; + } + let start = match memchr::memmem::find(path, b"...") { + Some(start) => start, + None => return false, + }; + let end = start + path[start..].iter().take_while(|&&c| c == b'.').count(); + let delimits = |c: u8| c == b':' || self.is_sep(c); + (start == 0 || delimits(path[start - 1])) && (end == path.len() || delimits(path[end])) + } + // `File.join(a, b)` pub fn join(self, a: &[u8], b: &[u8]) -> Vec { let tail = self.chomp_dir_sep(a); @@ -246,6 +262,18 @@ mod tests { assert_eq!(W.ntfs_tail(b"foo/"), 3); } + #[test] + fn it_finds_names_of_dots() { + assert!(W.has_dots_name(b"dir/...")); + assert!(W.has_dots_name(b"...")); + assert!(W.has_dots_name(b"C:..../a")); + assert!(W.has_dots_name(b"a\\...\\b")); + assert!(!W.has_dots_name(b"dir/a...")); + assert!(!W.has_dots_name(b"dir/..a")); + assert!(!W.has_dots_name(b"dir/.")); + assert!(!U.has_dots_name(b"dir/...")); + } + #[test] fn it_joins_like_file_join() { assert_eq!(U.join(b"a", b"b"), b"a/b"); diff --git a/src/pathname.rs b/src/pathname.rs index 28e99c7..6d50d66 100644 --- a/src/pathname.rs +++ b/src/pathname.rs @@ -145,7 +145,9 @@ pub fn pn_is_directory(arguments: &[AnyObject]) -> RubyResult { check_max_arguments(arguments, 1)?; let argument = PathArgument::new(arguments, 0)?; let path = argument.path()?; - let is_directory = path.os_path().map_or(false, |os_path| os_path.is_dir()); + // Windows takes "dir\\..." for "dir"; Ruby doesn't. + let is_directory = !RULES.has_dots_name(path.bytes()) && + path.os_path().map_or(false, |os_path| os_path.is_dir()); Ok(Boolean::new(is_directory).into()) } diff --git a/test/add_trailing_separator_test.rb b/test/add_trailing_separator_test.rb index e95d67a..c89f517 100644 --- a/test/add_trailing_separator_test.rb +++ b/test/add_trailing_separator_test.rb @@ -23,12 +23,13 @@ def test_add_trailing_separator def test_add_trailing_separator_in_dosish_context if File.dirname('A:') == 'A:.' - assert_equal FasterPath.add_trailing_separator('A:'), 'A:/' - assert_equal FasterPath.add_trailing_separator('A:/'), 'A:/' - assert_equal FasterPath.add_trailing_separator('A://'), 'A://' - assert_equal FasterPath.add_trailing_separator('A:.'), 'A:./' - assert_equal FasterPath.add_trailing_separator('A:./'), 'A:./' - assert_equal FasterPath.add_trailing_separator('A:.//'), 'A:.//' + # As in Ruby's test_pathname.rb + assert_equal 'A:', FasterPath.add_trailing_separator('A:') + assert_equal 'A:/', FasterPath.add_trailing_separator('A:/') + assert_equal 'A://', FasterPath.add_trailing_separator('A://') + assert_equal 'A:./', FasterPath.add_trailing_separator('A:.') + assert_equal 'A:./', FasterPath.add_trailing_separator('A:./') + assert_equal 'A:.//', FasterPath.add_trailing_separator('A:.//') end end diff --git a/test/cleanpath_conservative_test.rb b/test/cleanpath_conservative_test.rb index 345a1de..90d0e35 100644 --- a/test/cleanpath_conservative_test.rb +++ b/test/cleanpath_conservative_test.rb @@ -46,7 +46,8 @@ def test_it_keeps_every_name_with_a_trailing_separator def test_windows_compat if DOSISH - assert_equal FasterPath.cleanpath_conservative('c:/foo/bar'), 'c:\\foo\\bar' + # Ruby's test_pathname.rb: defassert(:cleanpath_conservative, 'c:/foo/bar', 'c:\\foo\\bar') + assert_equal 'c:/foo/bar', FasterPath.cleanpath_conservative('c:\\foo\\bar') end if DOSISH_UNC From e4690935d61eb1f317e45529bf7a3f99f0354202 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:03:08 +0000 Subject: [PATCH 06/11] Detect Ruby 2.7's extname from "a./", and fix test failure reports Ruby 2.7 returns an extension of only a dot (`File.extname("a./")` is "."), where earlier versions return "". FasterPath checked this with `File.extname("a.")`, which is "" on Windows whatever the version since NTFS drops trailing dots, so on Windows with Ruby 2.7 it returned "" for "a./". It now checks "a./", which tells on every platform. color_pound_spec_reporter prints failures using `MiniTest`, which minitest 5.19 and later don't define, so a failing test crashed the reporter; the test helper defines it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- src/extname.rs | 7 ++++--- src/lib.rs | 4 +++- test/test_helper.rb | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/extname.rs b/src/extname.rs index 8d17aa1..5667d1b 100644 --- a/src/extname.rs +++ b/src/extname.rs @@ -2,9 +2,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::path_parsing::Rules; -// Ruby 2.7 changed `File.extname("foo.")` from `""` to `"."` (except on -// Windows, where trailing dots aren't part of a file name). `FasterPath` -// follows the Ruby it is loaded into; see `Init_faster_pathname`. +// Ruby 2.7 changed `File.extname` to return an extension of only a dot +// (`"foo."` on Unix, `"foo./"` on both Unix and Windows, where trailing dots +// aren't part of a file name) instead of `""`. `FasterPath` follows the Ruby +// it is loaded into; see `Init_faster_pathname`. static TRAILING_DOT_IS_EXTNAME: AtomicBool = AtomicBool::new(false); pub fn set_trailing_dot_is_extname(value: bool) { diff --git a/src/lib.rs b/src/lib.rs index 0bcab22..1f691b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,8 +106,10 @@ ruby_methods! { #[allow(non_snake_case)] #[no_mangle] pub extern "C" fn Init_faster_pathname() { + // An extension of only a dot: "" before Ruby 2.7, "." since. ("a." would + // tell on Unix, but Windows doesn't count trailing dots.) let trailing_dot_extname = Class::file(). - protect_send("extname", &[RString::new_utf8("a.").into()]). + protect_send("extname", &[RString::new_utf8("a./").into()]). ok(). and_then(|extname| extname.try_convert_to::().ok()). map_or(false, |extname| extname.to_bytes_unchecked() == b"."); diff --git a/test/test_helper.rb b/test/test_helper.rb index 5b66016..514defb 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,6 +22,10 @@ require 'minitest/reporters' require 'color_pound_spec_reporter' +# color_pound_spec_reporter prints failures with the `MiniTest` name, which +# minitest 5.19 and later only define for compatibility +MiniTest = Minitest unless defined?(MiniTest) + Minitest::Reporters.use! [ColorPoundSpecReporter.new] ::Minitest::Assertions.module_eval do From 0bc2f84aecea65bf20b2d3aa5d6928e503cc9314 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:15:34 +0000 Subject: [PATCH 07/11] Document benchmarks before and after the Rutie 0.10 upgrade The README's "Time Shaved Off" tables now hold current pbench results for Ruby 2.7 and 2.5, including the `_compat` methods, and `join` is listed against `Pathname#join` under its own name. A new Benchmarks section says how they're measured (median of 3 runs of `LONG_RUN=10 rake pbench`) and compares every method before and after the upgrade. The Status list no longer calls Windows and encoding support "underway". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- README.md | 93 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b336a38..43cac66 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ As mentioned earlier Sprockets, which handles assets, changed away from using `P * Methods are stable * Thoroughly tested * Testers and developers are most welcome -* Windows & encoding support is underway! +* Windows: paths follow Ruby's Windows rules (`\\` separators, drive letters, UNC paths) +* Paths keep their encoding, as with Ruby's own methods ## Requirements @@ -159,25 +160,27 @@ require "faster_path" Current methods implemented: -|FasterPath Rust Implementation|Ruby 2.5.0 Implementation|Time Shaved Off| -|---|---|:---:| -| `FasterPath.absolute?` | `Pathname#absolute?` | 95.3% | -| `FasterPath.add_trailing_separator` | `Pathname#add_trailing_separator` | 48.4% | -| `FasterPath.basename` | `File.basename` | 12.0% | -| `FasterPath.children` | `Pathname#children` | 34.4% | -| `FasterPath.chop_basename` | `Pathname#chop_basename` | 83.4% | -| `FasterPath.cleanpath_aggressive` | `Pathname#cleanpath_aggressive` | 94.1% | -| `FasterPath.cleanpath_conservative` | `Pathname#cleanpath_conservative` | 93.5% | -| `FasterPath.del_trailing_separator` | `Pathname#del_trailing_separator` | 85.4% | -| `FasterPath.directory?` | `Pathname#directory?` | 6.4% | -| `FasterPath.dirname` | `File.dirname` | 55.4% | -| `FasterPath.entries` | `Pathname#entries` | 41.0% | -| `FasterPath.extname` | `File.extname` | 63.1% | -| `FasterPath.has_trailing_separator?` | `Pathname#has_trailing_separator` | 88.9% | -| `FasterPath.plus` | `Pathname#join` | 79.1% | -| `FasterPath.plus` | `Pathname#plus` | 94.7% | -| `FasterPath.relative?` | `Pathname#relative?` | 92.6% | -| `FasterPath.relative_path_from` | `Pathname#relative_path_from` | 93.3% | +|FasterPath Rust Implementation|Ruby Implementation|Time Shaved Off (Ruby 2.7)|Time Shaved Off (Ruby 2.5)| +|---|---|:---:|:---:| +| `FasterPath.absolute?` | `Pathname#absolute?` | 95.7% | 96.7% | +| `FasterPath.add_trailing_separator` | `Pathname#add_trailing_separator` | 85.4% | 83.0% | +| `FasterPath.basename` | `File.basename` | 44.2% | 28.9% | +| `FasterPath.children` | `Pathname#children` | 54.7% | 47.8% | +| `FasterPath.chop_basename` | `Pathname#chop_basename` | 70.4% | 75.1% | +| `FasterPath.cleanpath_aggressive` | `Pathname#cleanpath_aggressive` | 90.3% | 90.6% | +| `FasterPath.cleanpath_conservative` | `Pathname#cleanpath_conservative` | 89.6% | 91.6% | +| `FasterPath.del_trailing_separator` | `Pathname#del_trailing_separator` | 85.3% | 87.6% | +| `FasterPath.directory?` | `Pathname#directory?` | 34.3% | 37.5% | +| `FasterPath.dirname` | `File.dirname` | 56.8% | 54.7% | +| `FasterPath.entries` | `Pathname#entries` | 38.0% | 33.9% | +| `FasterPath.extname` | `File.extname` | 74.0% | 73.1% | +| `FasterPath.has_trailing_separator?` | `Pathname#has_trailing_separator` | 88.5% | 89.1% | +| `FasterPath.join` | `Pathname#join` | 88.1% | 90.3% | +| `FasterPath.plus` | `Pathname#plus` | 91.7% | 93.4% | +| `FasterPath.relative?` | `Pathname#relative?` | 92.8% | 95.2% | +| `FasterPath.relative_path_from` | `Pathname#relative_path_from` | 92.5% | 93.6% | + +See [Benchmarks](#benchmarks) for how these are measured. You may choose to use the methods directly, or scope change to rewrite behavior on the standard library with the included refinements, or even call a method to monkeypatch @@ -203,14 +206,56 @@ FasterPath.sledgehammer_everything! These will **not** be included by default in monkey-patches. To try them with monkeypatching use the environment flag of `WITH_REGRESSION`. These methods are here to be improved upon. -|FasterPath Implementation|Ruby Implementation| -|---|---| -| `FasterPath.entries_compat` | `Pathname.entries` | -| `FasterPath.children_compat` | `Pathname.children` | +|FasterPath Implementation|Ruby Implementation|Time Shaved Off (Ruby 2.7)|Time Shaved Off (Ruby 2.5)| +|---|---|:---:|:---:| +| `FasterPath.entries_compat` | `Pathname.entries` | 16.8% | 9.2% | +| `FasterPath.children_compat` | `Pathname.children` | 32.1% | 26.6% | It's been my observation (and some others) that the Rust implementation of the C code for `File` has similar results but performance seems to vary based on CPU cache on possibly 64bit/32bit system environments. These are not included by default when the monkey patch method `FasterPath.sledgehammer_everything!` is executed. +## Benchmarks + +The "Time Shaved Off" figures come from the project's Pinch-bench (`rake pbench`, in +`test/pbench`): each method runs against Ruby's own implementation, and the result is how much +less time FasterPath took. They are the median of 3 runs of `LONG_RUN=10 rake pbench` (500,000 +calls of each method, 5 times) on Linux x86_64 with Ruby 2.7.8 and 2.5.7, built with Rust 1.97. +Expect a few points of difference from run to run. + +### Before and after the Rutie 0.10 upgrade + +Measured the same way, alternating runs of the code before the upgrade (built with Rutie 0.6) +and after it. Higher is better; the change is in percentage points. + +|Method|Ruby 2.7 before|Ruby 2.7 after|Change|Ruby 2.5 before|Ruby 2.5 after|Change| +|---|:---:|:---:|:---:|:---:|:---:|:---:| +| `absolute?` | 93.3% | 95.7% | +2.4 | 96.1% | 96.7% | +0.6 | +| `add_trailing_separator` | 66.6% | 85.4% | **+18.8** | 64.3% | 83.0% | **+18.7** | +| `basename` | 42.4% | 44.2% | +1.8 | 34.6% | 28.9% | −5.7 | +| `children` | 52.8% | 54.7% | +1.9 | 46.5% | 47.8% | +1.3 | +| `children_compat` | −1.2% | 32.1% | **+33.3** | 1.1% | 26.6% | **+25.5** | +| `chop_basename` | 71.2% | 70.4% | −0.8 | 74.7% | 75.1% | +0.4 | +| `cleanpath_aggressive` | 90.3% | 90.3% | 0.0 | 92.7% | 90.6% | −2.1 | +| `cleanpath_conservative` | 89.8% | 89.6% | −0.2 | 91.7% | 91.6% | −0.1 | +| `del_trailing_separator` | 86.5% | 85.3% | −1.2 | 87.0% | 87.6% | +0.6 | +| `directory?` | 33.2% | 34.3% | +1.1 | 37.2% | 37.5% | +0.3 | +| `dirname` | 49.8% | 56.8% | **+7.0** | 51.2% | 54.7% | +3.5 | +| `entries` | 40.4% | 38.0% | −2.4 | 33.6% | 33.9% | +0.3 | +| `entries_compat` | −29.8% | 16.8% | **+46.6** | −23.3% | 9.2% | **+32.5** | +| `extname` | 70.3% | 74.0% | +3.7 | 74.0% | 73.1% | −0.9 | +| `has_trailing_separator?` | 87.5% | 88.5% | +1.0 | 87.1% | 89.1% | +2.0 | +| `join` | crashes | 88.1% | | crashes | 90.3% | | +| `plus` | 92.3% | 91.7% | −0.6 | 93.0% | 93.4% | +0.4 | +| `relative?` | 90.7% | 92.8% | +2.1 | 93.6% | 95.2% | +1.6 | +| `relative_path_from` | 92.7% | 92.5% | −0.2 | 94.0% | 93.6% | −0.4 | + +Before the upgrade, `FasterPath.join` segfaulted, so its benchmark can't be measured. +`add_trailing_separator`, `dirname` and the `_compat` methods gained the most: +`add_trailing_separator` no longer builds a temporary string, no method checks its arguments +for valid UTF-8 any more, and the `_compat` methods build `Pathname` objects without calling +`Pathname.new`. `basename` on Ruby 2.5 is the one notable loss, though its runs +overlapped (before: 29.8%, 34.6%, 36.2%; after: 28.7%, 28.9%, 35.6%). + ## Getting Started with Development The primary methods to target are mostly listed in the **Why** section above. You may find the Ruby From e17f46ab51162e8295e5c9ca5e03602c8897fb12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:15:42 +0000 Subject: [PATCH 08/11] Fix the separator shown in the README's Windows status line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43cac66..ca0a19a 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ As mentioned earlier Sprockets, which handles assets, changed away from using `P * Methods are stable * Thoroughly tested * Testers and developers are most welcome -* Windows: paths follow Ruby's Windows rules (`\\` separators, drive letters, UNC paths) +* Windows: paths follow Ruby's Windows rules (`\` and `/` separators, drive letters, UNC paths) * Paths keep their encoding, as with Ruby's own methods ## Requirements From b1e5d1c115643bdc1a0f983d7acf85e991d10c4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:38:19 +0000 Subject: [PATCH 09/11] Speed up basename back past the old implementation On Unix, find the last name with a plain scan instead of the general Windows-capable path, skip extension handling when no extension is given, and only merge encodings when they differ. Callgrind now counts 2331 instructions per call on Ruby 2.5 (was 2502; the old implementation 2439) and 2561 on Ruby 2.7 (old implementation 2689). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- src/basename.rs | 25 ++++++++++++++++++++----- src/pathname.rs | 5 +++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/basename.rs b/src/basename.rs index 0917423..31e7cb6 100644 --- a/src/basename.rs +++ b/src/basename.rs @@ -20,13 +20,24 @@ pub fn last_component(rules: Rules, path: &[u8]) -> (&[u8], Option) { if path.is_empty() { return (path, None); } + if !rules.is_dosish() { + // On Unix this is just the last name, or the last separator. + let end = match rules.last_non_sep_pos(path) { + Some(pos) => pos + 1, + None => return (&path[path.len() - 1..], None), + }; + // Paths are short: a plain scan beats `memrchr`'s setup here. + let start = path[..end].iter().rposition(|&c| c == b'/').map_or(0, |pos| pos + 1); + let component = &path[start..end]; + return (component, Some(base_len(component))); + } let root = rules.skip_prefix(path); let mut name = root; while name < path.len() && rules.is_sep(path[name]) { name += 1; } if name == path.len() { - if !rules.is_dosish() || name != root { + if name != root { // The last separator return (&path[name - 1..name], None); } @@ -48,11 +59,15 @@ pub fn last_component(rules: Rules, path: &[u8]) -> (&[u8], Option) { None => name, }; let rest = &path[start..]; - let len = if rules.is_dosish() { rules.ntfs_tail(rest) } else { rules.chomp_dir_sep(rest) }; - let component = &rest[..len]; + let component = &rest[..rules.ntfs_tail(rest)]; + (component, Some(base_len(component))) +} + +// Where the name ends without its extension: at its last dot, unless that +// is one of its leading dots. +fn base_len(component: &[u8]) -> usize { let dots = component.iter().take_while(|&&c| c == b'.').count(); - let base_len = component[dots..].iter().rposition(|&c| c == b'.').map_or(len, |pos| dots + pos); - (component, Some(base_len)) + component[dots..].iter().rposition(|&c| c == b'.').map_or(component.len(), |pos| dots + pos) } // Ruby's `rmext`: where `component` ends without the extension `ext`, or diff --git a/src/pathname.rs b/src/pathname.rs index 6d50d66..5a88f51 100644 --- a/src/pathname.rs +++ b/src/pathname.rs @@ -52,9 +52,10 @@ pub fn pn_basename(arguments: &[AnyObject]) -> RubyResult { let result = match base_len { // Like Ruby, ignore an extension in an incompatible encoding, and only // remove one that starts a character. - Some(base_len) if EncodingOf::new(&path).merge(&ext).is_ok() => { + Some(base_len) if !ext.bytes().is_empty() && + (path.encoding == ext.encoding || EncodingOf::new(&path).merge(&ext).is_ok()) => { let end = basename::ext_end(RULES, component, base_len, ext.bytes()); - if path.is_char_boundary(component, end) { &component[..end] } else { component } + if end == component.len() || path.is_char_boundary(component, end) { &component[..end] } else { component } } _ => component, }; From a908ed82cf2e94074cfeb7d55779db27dc0c3b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:54:46 +0000 Subject: [PATCH 10/11] Document basename's benchmarks after the Unix fast path Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ca0a19a..7602493 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Current methods implemented: |---|---|:---:|:---:| | `FasterPath.absolute?` | `Pathname#absolute?` | 95.7% | 96.7% | | `FasterPath.add_trailing_separator` | `Pathname#add_trailing_separator` | 85.4% | 83.0% | -| `FasterPath.basename` | `File.basename` | 44.2% | 28.9% | +| `FasterPath.basename` | `File.basename` | 42.4% | 39.1% | | `FasterPath.children` | `Pathname#children` | 54.7% | 47.8% | | `FasterPath.chop_basename` | `Pathname#chop_basename` | 70.4% | 75.1% | | `FasterPath.cleanpath_aggressive` | `Pathname#cleanpath_aggressive` | 90.3% | 90.6% | @@ -231,7 +231,7 @@ and after it. Higher is better; the change is in percentage points. |---|:---:|:---:|:---:|:---:|:---:|:---:| | `absolute?` | 93.3% | 95.7% | +2.4 | 96.1% | 96.7% | +0.6 | | `add_trailing_separator` | 66.6% | 85.4% | **+18.8** | 64.3% | 83.0% | **+18.7** | -| `basename` | 42.4% | 44.2% | +1.8 | 34.6% | 28.9% | −5.7 | +| `basename` | 42.4% | 42.4% | 0.0 | 32.1% | 39.1% | **+7.0** | | `children` | 52.8% | 54.7% | +1.9 | 46.5% | 47.8% | +1.3 | | `children_compat` | −1.2% | 32.1% | **+33.3** | 1.1% | 26.6% | **+25.5** | | `chop_basename` | 71.2% | 70.4% | −0.8 | 74.7% | 75.1% | +0.4 | @@ -253,8 +253,11 @@ Before the upgrade, `FasterPath.join` segfaulted, so its benchmark can't be meas `add_trailing_separator`, `dirname` and the `_compat` methods gained the most: `add_trailing_separator` no longer builds a temporary string, no method checks its arguments for valid UTF-8 any more, and the `_compat` methods build `Pathname` objects without calling -`Pathname.new`. `basename` on Ruby 2.5 is the one notable loss, though its runs -overlapped (before: 29.8%, 34.6%, 36.2%; after: 28.7%, 28.9%, 35.6%). +`Pathname.new`. `basename` was re-measured after it was made to take a shorter path on Unix +and skip an empty extension; it was the one method that first measured slower on Ruby 2.5. +Its timings vary a lot from run to run (Ruby 2.5 before: 40.0%, 32.1%, 28.7%; after: 29.6%, +39.1%, 39.2%), so its instruction counts from `valgrind --tool=callgrind` are a steadier +measure. They went from 2439 to 2331 per call on Ruby 2.5 and from 2689 to 2561 on Ruby 2.7. ## Getting Started with Development From 4ee448bff29706160be0300667f0bfd4eae4c66c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 17:12:38 +0000 Subject: [PATCH 11/11] Bump the gem to 0.4.0 and the crate to 0.1.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- lib/faster_path/version.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a6d6c4..0c87d7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "faster_path" -version = "0.0.1" +version = "0.1.0" dependencies = [ "memchr", "rutie", diff --git a/Cargo.toml b/Cargo.toml index 7a4950e..0982b33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "faster_path" -version = "0.0.1" +version = "0.1.0" authors = ["Daniel P. Clark <6ftdan@gmail.com>"] description = "Alternative to Pathname" homepage = "https://github.com/danielpclark/faster_path" diff --git a/README.md b/README.md index 7602493..43b7f97 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ curl -sSf https://sh.rustup.rs | sh Add this line to your application's Gemfile: ```ruby -gem 'faster_path', '~> 0.3.10' +gem 'faster_path', '~> 0.4.0' ``` And then execute: diff --git a/lib/faster_path/version.rb b/lib/faster_path/version.rb index 5ca5387..54bd02d 100644 --- a/lib/faster_path/version.rb +++ b/lib/faster_path/version.rb @@ -1,3 +1,3 @@ module FasterPath - VERSION = "0.3.10" + VERSION = "0.4.0" end