From 9f4c3a1d10b7141ac7245e1d65a8880050cf7c33 Mon Sep 17 00:00:00 2001 From: Justin Ma Date: Mon, 4 Aug 2025 22:32:31 +0800 Subject: [PATCH] Fix path relative-to for case-insensitive filesystems (#16310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #16205 # Description 1. **Adds fallback**: On case-insensitive filesystems (Windows, macOS), falls back to case-insensitive comparison when the standard comparison fails 2. **Maintains filesystem semantics**: Only uses case-insensitive comparison on platforms where it's appropriate ## Before: ```console $> "/etc" | path relative-to "/Etc" Error: nu::shell::cant_convert × Can't convert to prefix not found. ╭─[entry #33:1:1] 1 │ "/etc" | path relative-to "/Etc" · ───┬── · ╰── can't convert string to prefix not found ╰──── ``` ## After: For Windows and macOS: ```console $> "/etc" | path relative-to "/Etc" | debug -v "" ``` --- crates/nu-command/src/path/relative_to.rs | 168 ++++++++++++++++++++-- 1 file changed, 160 insertions(+), 8 deletions(-) diff --git a/crates/nu-command/src/path/relative_to.rs b/crates/nu-command/src/path/relative_to.rs index d63c3e2bb0..88575e0db4 100644 --- a/crates/nu-command/src/path/relative_to.rs +++ b/crates/nu-command/src/path/relative_to.rs @@ -144,17 +144,84 @@ path."# fn relative_to(path: &Path, span: Span, args: &Arguments) -> Value { let lhs = expand_to_real_path(path); let rhs = expand_to_real_path(&args.path.item); + match lhs.strip_prefix(&rhs) { Ok(p) => Value::string(p.to_string_lossy(), span), - Err(e) => Value::error( - ShellError::CantConvert { - to_type: e.to_string(), - from_type: "string".into(), + Err(e) => { + // On case-insensitive filesystems, try case-insensitive comparison + if is_case_insensitive_filesystem() { + if let Some(relative_path) = try_case_insensitive_strip_prefix(&lhs, &rhs) { + return Value::string(relative_path.to_string_lossy(), span); + } + } + + Value::error( + ShellError::CantConvert { + to_type: e.to_string(), + from_type: "string".into(), + span, + help: None, + }, span, - help: None, - }, - span, - ), + ) + } + } +} + +/// Check if the current filesystem is typically case-insensitive +fn is_case_insensitive_filesystem() -> bool { + // Windows and macOS typically have case-insensitive filesystems + cfg!(any(target_os = "windows", target_os = "macos")) +} + +/// Try to strip prefix in a case-insensitive manner +fn try_case_insensitive_strip_prefix(lhs: &Path, rhs: &Path) -> Option { + let mut lhs_components = lhs.components(); + let mut rhs_components = rhs.components(); + + // Compare components case-insensitively + loop { + match (lhs_components.next(), rhs_components.next()) { + (Some(lhs_comp), Some(rhs_comp)) => { + match (lhs_comp, rhs_comp) { + ( + std::path::Component::Normal(lhs_name), + std::path::Component::Normal(rhs_name), + ) => { + if lhs_name.to_string_lossy().to_lowercase() + != rhs_name.to_string_lossy().to_lowercase() + { + return None; + } + } + // Non-Normal components must match exactly + _ if lhs_comp != rhs_comp => { + return None; + } + _ => {} + } + } + (Some(lhs_comp), None) => { + // rhs is fully consumed, but lhs has more components + // This means rhs is a prefix of lhs, collect remaining lhs components + let mut result = std::path::PathBuf::new(); + // Add the current lhs component that wasn't matched + result.push(lhs_comp); + // Add all remaining lhs components + for component in lhs_components { + result.push(component); + } + return Some(result); + } + (None, Some(_)) => { + // lhs is shorter than rhs, so rhs cannot be a prefix of lhs + return None; + } + (None, None) => { + // Both paths have the same components, relative path is empty + return Some(std::path::PathBuf::new()); + } + } } } @@ -168,4 +235,89 @@ mod tests { test_examples(PathRelativeTo {}) } + + #[test] + fn test_case_insensitive_filesystem() { + use nu_protocol::{Span, Value}; + use std::path::Path; + + let args = Arguments { + path: Spanned { + item: "/Etc".to_string(), + span: Span::test_data(), + }, + }; + + let result = relative_to(Path::new("/etc"), Span::test_data(), &args); + + // On case-insensitive filesystems (Windows, macOS), this should work + // On case-sensitive filesystems (Linux, FreeBSD), this should fail + if is_case_insensitive_filesystem() { + match result { + Value::String { val, .. } => { + assert_eq!(val, ""); + } + _ => panic!("Expected string result on case-insensitive filesystem"), + } + } else { + match result { + Value::Error { .. } => { + // Expected on case-sensitive filesystems + } + _ => panic!("Expected error on case-sensitive filesystem"), + } + } + } + + #[test] + fn test_case_insensitive_with_subpath() { + use nu_protocol::{Span, Value}; + use std::path::Path; + + let args = Arguments { + path: Spanned { + item: "/Home/User".to_string(), + span: Span::test_data(), + }, + }; + + let result = relative_to(Path::new("/home/user/documents"), Span::test_data(), &args); + + if is_case_insensitive_filesystem() { + match result { + Value::String { val, .. } => { + assert_eq!(val, "documents"); + } + _ => panic!("Expected string result on case-insensitive filesystem"), + } + } else { + match result { + Value::Error { .. } => { + // Expected on case-sensitive filesystems + } + _ => panic!("Expected error on case-sensitive filesystem"), + } + } + } + + #[test] + fn test_truly_different_paths() { + use nu_protocol::{Span, Value}; + use std::path::Path; + + let args = Arguments { + path: Spanned { + item: "/Different/Path".to_string(), + span: Span::test_data(), + }, + }; + + let result = relative_to(Path::new("/home/user"), Span::test_data(), &args); + + // This should fail on all filesystems since paths are truly different + match result { + Value::Error { .. } => {} + _ => panic!("Expected error for truly different paths"), + } + } }