From f3eecab1c6bd4ba3e67ec7bc5ba4768a4de897bc Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 2 Oct 2026 16:43:56 +0200 Subject: [PATCH] Upstream zed-industries/zed state of the touched files before c1ac0b23f7 --- crates/util/src/paths.rs | 3405 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 3405 insertions(+) create mode 100644 crates/util/src/paths.rs diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs new file mode 100644 index 0000000..90ce3f8 --- /dev/null +++ b/crates/util/src/paths.rs @@ -0,0 +1,3405 @@ +use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder}; +use itertools::Itertools; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::mem; +use std::path::StripPrefixError; +use std::sync::Arc; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use path::rel_path::RelPath; +use path::rel_path::RelPathBuf; + +pub use path::PathStyle; + +/// Returns the path to the user's home directory. +/// +/// This crate's own tests see a fixed fake path. Every other build, including +/// builds with `test-support`, sees the real home directory. +#[cfg(not(target_family = "wasm"))] +pub fn home_dir() -> &'static PathBuf { + static HOME_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + HOME_DIR.get_or_init(|| { + if cfg!(test) { + if cfg!(target_os = "macos") { + PathBuf::from("/Users/zed") + } else if cfg!(target_os = "windows") { + PathBuf::from("C:\\Users\\zed") + } else { + PathBuf::from("/home/zed") + } + } else { + dirs::home_dir().expect("failed to determine home directory") + } + }) +} + +pub trait PathExt { + /// Compacts a given file path by replacing the user's home directory + /// prefix with a tilde (`~`). + /// + /// # Returns + /// + /// * A `PathBuf` containing the compacted file path. If the input path + /// does not have the user's home directory prefix, or if we are not on + /// Linux or macOS, the original path is returned unchanged. + fn compact(&self) -> PathBuf; + + /// Returns a file's extension or, if the file is hidden, its name without the leading dot + fn extension_or_hidden_file_name(&self) -> Option<&str>; + + fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result + where + Self: From<&'a Path>, + { + #[cfg(target_family = "wasm")] + { + std::str::from_utf8(bytes) + .map(Path::new) + .map(Into::into) + .map_err(Into::into) + } + #[cfg(unix)] + { + use std::os::unix::prelude::OsStrExt; + Ok(Self::from(Path::new(OsStr::from_bytes(bytes)))) + } + #[cfg(windows)] + { + use anyhow::Context; + use tendril::fmt::{Format, WTF8}; + WTF8::validate(bytes) + .then(|| { + // Safety: bytes are valid WTF-8 sequence. + Self::from(Path::new(unsafe { + OsStr::from_encoded_bytes_unchecked(bytes) + })) + }) + .with_context(|| format!("Invalid WTF-8 sequence: {bytes:?}")) + } + } + + /// Converts a local path to one that can be used inside of WSL. + /// Returns `None` if the path cannot be converted into a WSL one (network share). + fn local_to_wsl(&self) -> Option; + + /// Returns a file's "full" joined collection of extensions, in the case where a file does not + /// just have a singular extension but instead has multiple (e.g File.tar.gz, Component.stories.tsx) + /// + /// Will provide back the extensions joined together such as tar.gz or stories.tsx + fn multiple_extensions(&self) -> Option; + + /// Try to make a shell-safe representation of the path. + #[cfg(not(target_family = "wasm"))] + fn try_shell_safe(&self, shell_kind: crate::shell::ShellKind) -> anyhow::Result; +} + +impl> PathExt for T { + fn compact(&self) -> PathBuf { + #[cfg(target_family = "wasm")] + { + self.as_ref().to_path_buf() + } + #[cfg(not(target_family = "wasm"))] + if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") { + match self.as_ref().strip_prefix(home_dir().as_path()) { + Ok(relative_path) => { + let mut shortened_path = PathBuf::new(); + shortened_path.push("~"); + shortened_path.push(relative_path); + shortened_path + } + Err(_) => self.as_ref().to_path_buf(), + } + } else { + self.as_ref().to_path_buf() + } + } + + fn extension_or_hidden_file_name(&self) -> Option<&str> { + let path = self.as_ref(); + let file_name = path.file_name()?.to_str()?; + if file_name.starts_with('.') { + return file_name.strip_prefix('.'); + } + + path.extension() + .and_then(|e| e.to_str()) + .or_else(|| path.file_stem()?.to_str()) + } + + fn local_to_wsl(&self) -> Option { + // quite sketchy to convert this back to path at the end, but a lot of functions only accept paths + // todo: ideally rework them..? + let mut new_path = std::ffi::OsString::new(); + for component in self.as_ref().components() { + match component { + std::path::Component::Prefix(prefix) => { + let drive_letter = prefix.as_os_str().to_string_lossy().to_lowercase(); + let drive_letter = drive_letter.strip_suffix(':')?; + + new_path.push(format!("/mnt/{}", drive_letter)); + } + std::path::Component::RootDir => {} + std::path::Component::CurDir => { + new_path.push("/."); + } + std::path::Component::ParentDir => { + new_path.push("/.."); + } + std::path::Component::Normal(os_str) => { + new_path.push("/"); + new_path.push(os_str); + } + } + } + + Some(new_path.into()) + } + + fn multiple_extensions(&self) -> Option { + let path = self.as_ref(); + let file_name = path.file_name()?.to_str()?; + + let parts: Vec<&str> = file_name + .split('.') + // Skip the part with the file name extension + .skip(1) + .collect(); + + if parts.len() < 2 { + return None; + } + + Some(parts.into_iter().join(".")) + } + + #[cfg(not(target_family = "wasm"))] + fn try_shell_safe(&self, shell_kind: crate::shell::ShellKind) -> anyhow::Result { + use anyhow::Context; + let path_str = self + .as_ref() + .to_str() + .with_context(|| "Path contains invalid UTF-8")?; + shell_kind + .try_quote(path_str) + .as_deref() + .map(ToOwned::to_owned) + .context("Failed to quote path") + } +} + +pub fn path_ends_with(base: &Path, suffix: &Path) -> bool { + strip_path_suffix(base, suffix).is_some() +} + +/// Case-insensitive ASCII comparison of a path component to a literal +/// folder name. macOS and Windows use case-insensitive filesystems by +/// default, so a path like `.ZED/settings.json` resolves to the same +/// inode as the lowercase form. A case-sensitive `==` check would miss +/// those and let a malicious settings author bypass classifiers with +/// unusual casing. Callers should restrict `name` to ASCII; for ASCII +/// inputs `eq_ignore_ascii_case` is safe and stable across platforms. +pub fn component_matches_ignore_ascii_case(component: &OsStr, name: &str) -> bool { + component + .to_str() + .is_some_and(|s| s.eq_ignore_ascii_case(name)) +} + +pub fn strip_path_suffix<'a>(base: &'a Path, suffix: &Path) -> Option<&'a Path> { + if let Some(remainder) = base + .as_os_str() + .as_encoded_bytes() + .strip_suffix(suffix.as_os_str().as_encoded_bytes()) + { + if remainder + .last() + .is_none_or(|last_byte| std::path::is_separator(*last_byte as char)) + { + let os_str = unsafe { + OsStr::from_encoded_bytes_unchecked( + &remainder[0..remainder.len().saturating_sub(1)], + ) + }; + return Some(Path::new(os_str)); + } + } + None +} + +/// In memory, this is identical to `Path`. On non-Windows conversions to this type are no-ops. On +/// windows, these conversions sanitize UNC paths by removing the `\\\\?\\` prefix. +#[derive(Eq, PartialEq, Hash, Ord, PartialOrd)] +#[repr(transparent)] +pub struct SanitizedPath(Path); + +impl SanitizedPath { + pub fn new + ?Sized>(path: &T) -> &Self { + #[cfg(not(target_os = "windows"))] + return Self::unchecked_new(path.as_ref()); + + #[cfg(target_os = "windows")] + return Self::unchecked_new(dunce::simplified(path.as_ref())); + } + + pub fn unchecked_new + ?Sized>(path: &T) -> &Self { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::<&Path, &Self>(path.as_ref()) } + } + + pub fn from_arc(path: Arc) -> Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + #[cfg(not(target_os = "windows"))] + return unsafe { mem::transmute::, Arc>(path) }; + + #[cfg(target_os = "windows")] + { + let path = match path.to_str().and_then(|s| s.strip_prefix(r"\\?\UNC\")) { + Some(rest) => PathBuf::from(format!(r"\\{rest}")).into(), + None => path, + }; + let simplified = dunce::simplified(path.as_ref()); + if simplified == path.as_ref() { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } else { + Self::unchecked_new(simplified).into() + } + } + } + + pub fn new_arc + ?Sized>(path: &T) -> Arc { + Self::new(path).into() + } + + pub fn cast_arc(path: Arc) -> Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } + + pub fn cast_arc_ref(path: &Arc) -> &Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::<&Arc, &Arc>(path) } + } + + pub fn starts_with(&self, prefix: &Self) -> bool { + self.0.starts_with(&prefix.0) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn file_name(&self) -> Option<&std::ffi::OsStr> { + self.0.file_name() + } + + pub fn extension(&self) -> Option<&std::ffi::OsStr> { + self.0.extension() + } + + pub fn join>(&self, path: P) -> PathBuf { + self.0.join(path) + } + + pub fn parent(&self) -> Option<&Self> { + self.0.parent().map(Self::unchecked_new) + } + + pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> { + self.0.strip_prefix(base.as_path()) + } + + pub fn to_str(&self) -> Option<&str> { + self.0.to_str() + } + + pub fn to_path_buf(&self) -> PathBuf { + self.0.to_path_buf() + } +} + +impl std::fmt::Debug for SanitizedPath { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.0, formatter) + } +} + +impl Display for SanitizedPath { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.display()) + } +} + +impl From<&SanitizedPath> for Arc { + fn from(sanitized_path: &SanitizedPath) -> Self { + let path: Arc = sanitized_path.0.into(); + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute(path) } + } +} + +impl From<&SanitizedPath> for PathBuf { + fn from(sanitized_path: &SanitizedPath) -> Self { + sanitized_path.as_path().into() + } +} + +impl AsRef for SanitizedPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct RemotePathBuf { + style: PathStyle, + string: String, +} + +impl RemotePathBuf { + pub fn new(string: String, style: PathStyle) -> Self { + Self { style, string } + } + + pub fn from_str(path: &str, style: PathStyle) -> Self { + Self::new(path.to_string(), style) + } + + pub fn path_style(&self) -> PathStyle { + self.style + } + + pub fn to_proto(self) -> String { + self.string + } +} + +impl Display for RemotePathBuf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.string) + } +} + +pub fn is_absolute(path_like: &str, path_style: PathStyle) -> bool { + path_like.starts_with('/') + || path_style == PathStyle::Windows + && (path_like.starts_with('\\') + || path_like + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic()) + && path_like[1..] + .strip_prefix(':') + .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) +} + +#[derive(Debug, PartialEq)] +#[non_exhaustive] +pub struct NormalizeError; + +impl Error for NormalizeError {} + +impl std::fmt::Display for NormalizeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("parent reference `..` points outside of base directory") + } +} + +/// Copied from stdlib where it's unstable. +/// +/// Normalize a path, including `..` without traversing the filesystem. +/// +/// Returns an error if normalization would leave leading `..` components. +/// +///
+/// +/// This function always resolves `..` to the "lexical" parent. +/// That is "a/b/../c" will always resolve to `a/c` which can change the meaning of the path. +/// In particular, `a/c` and `a/b/../c` are distinct on many systems because `b` may be a symbolic link, so its parent isn't `a`. +/// +///
+/// +/// [`path::absolute`](absolute) is an alternative that preserves `..`. +/// Or [`Path::canonicalize`] can be used to resolve any `..` by querying the filesystem. +pub fn normalize_lexically(path: &Path) -> Result { + use std::path::Component; + + let mut lexical = PathBuf::new(); + let mut iter = path.components().peekable(); + + // Find the root, if any, and add it to the lexical path. + // Here we treat the Windows path "C:\" as a single "root" even though + // `components` splits it into two: (Prefix, RootDir). + let root = match iter.peek() { + Some(Component::ParentDir) => return Err(NormalizeError), + Some(p @ Component::RootDir) | Some(p @ Component::CurDir) => { + lexical.push(p); + iter.next(); + lexical.as_os_str().len() + } + Some(Component::Prefix(prefix)) => { + lexical.push(prefix.as_os_str()); + iter.next(); + if let Some(p @ Component::RootDir) = iter.peek() { + lexical.push(p); + iter.next(); + } + lexical.as_os_str().len() + } + None => return Ok(PathBuf::new()), + Some(Component::Normal(_)) => 0, + }; + + for component in iter { + match component { + Component::RootDir => unreachable!(), + Component::Prefix(_) => return Err(NormalizeError), + Component::CurDir => continue, + Component::ParentDir => { + // It's an error if ParentDir causes us to go above the "root". + if lexical.as_os_str().len() == root { + return Err(NormalizeError); + } else { + lexical.pop(); + } + } + Component::Normal(path) => lexical.push(path), + } + } + Ok(lexical) +} + +/// Insert `path` into a set of "subtree" grants, keeping the set minimal. +/// +/// A subtree grant covers a path and all of its descendants. Insertion is a +/// no-op when `path` is already covered by an existing (equal-or-broader) +/// entry; otherwise `path` is added and any now-subsumed descendant entries +/// are pruned. Containment is purely lexical (component-wise `starts_with`), +/// so callers should normalize paths (e.g. via [`normalize_lexically`]) before +/// inserting, otherwise `..` components can defeat the containment checks. +pub fn insert_subtree(subtrees: &mut Vec, path: PathBuf) { + if subtrees.iter().any(|existing| path.starts_with(existing)) { + return; + } + subtrees.retain(|existing| !existing.starts_with(&path)); + subtrees.push(path); +} + +/// Whether `path` sits under (or exactly equals) any of the given subtree +/// grants. As with [`insert_subtree`], containment is purely lexical, so +/// callers should pass normalized paths. +pub fn path_within_subtree<'a>(path: &Path, mut subtrees: impl Iterator) -> bool { + subtrees.any(|granted| path.starts_with(granted)) +} + +/// A delimiter to use in `path_query:row_number:column_number` strings parsing. +pub const FILE_ROW_COLUMN_DELIMITER: char = ':'; + +const ROW_COL_CAPTURE_REGEX: &str = r"(?xs) + ([^\(]+)\:(?: + \((\d+)[,:](\d+)\) # filename:(row,column), filename:(row:column) + | + \((\d+)\)() # filename:(row) + ) + | + ([^\(]+)(?: + \((\d+)[,:](\d+)\) # filename(row,column), filename(row:column) + | + \((\d+)\)() # filename(row) + ) + \:*$ + | + (.+?)(?: + \:+(\d+)\:(\d+)\:*$ # filename:row:column + | + \:+(\d+)\:*()$ # filename:row + | + \:+()()$ + )"; + +/// A representation of a path-like string with optional row and column numbers. +/// Matching values example: `te`, `test.rs:22`, `te:22:5`, `test.c(22)`, `test.c(22,5)`etc. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub struct PathWithPosition { + pub path: PathBuf, + pub row: Option, + // Absent if row is absent. + pub column: Option, +} + +impl PathWithPosition { + /// Returns a PathWithPosition from a path. + pub fn from_path(path: PathBuf) -> Self { + Self { + path, + row: None, + column: None, + } + } + + /// Parses a string that possibly has `:row:column` or `(row, column)` suffix. + /// Parenthesis format is used by [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks) compatible tools + /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`. + /// If the suffix parsing fails, the whole string is parsed as a path. + /// + /// Be mindful that `test_file:10:1:` is a valid posix filename. + /// `PathWithPosition` class assumes that the ending position-like suffix is **not** part of the filename. + /// + /// # Examples + /// + /// ``` + /// # use util::paths::PathWithPosition; + /// # use std::path::PathBuf; + /// assert_eq!(PathWithPosition::parse_str("test_file"), PathWithPosition { + /// path: PathBuf::from("test_file"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file:10"), PathWithPosition { + /// path: PathBuf::from("test_file"), + /// row: Some(10), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: Some(2), + /// }); + /// ``` + /// + /// # Expected parsing results when encounter ill-formatted inputs. + /// ``` + /// # use util::paths::PathWithPosition; + /// # use std::path::PathBuf; + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:a"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a:b"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:a:b"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1:2"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: Some(2), + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::2"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:1"), + /// row: Some(2), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2:3"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:1"), + /// row: Some(2), + /// column: Some(3), + /// }); + /// ``` + pub fn parse_str(s: &str) -> Self { + let trimmed = s.trim(); + let path = Path::new(trimmed); + let Some(maybe_file_name_with_row_col) = path.file_name().unwrap_or_default().to_str() + else { + return Self { + path: Path::new(s).to_path_buf(), + row: None, + column: None, + }; + }; + if maybe_file_name_with_row_col.is_empty() { + return Self { + path: Path::new(s).to_path_buf(), + row: None, + column: None, + }; + } + + // Let's avoid repeated init cost on this. It is subject to thread contention, but + // so far this code isn't called from multiple hot paths. Getting contention here + // in the future seems unlikely. + static SUFFIX_RE: LazyLock = + LazyLock::new(|| Regex::new(ROW_COL_CAPTURE_REGEX).unwrap()); + match SUFFIX_RE + .captures(maybe_file_name_with_row_col) + .map(|caps| caps.extract()) + { + Some((_, [file_name, maybe_row, maybe_column])) => { + let row = maybe_row.parse::().ok(); + let column = maybe_column.parse::().ok(); + + let (_, suffix) = trimmed.split_once(file_name).unwrap(); + let path_without_suffix = &trimmed[..trimmed.len() - suffix.len()]; + + Self { + path: Path::new(path_without_suffix).to_path_buf(), + row, + column, + } + } + None => { + // The `ROW_COL_CAPTURE_REGEX` deals with separated digits only, + // but in reality there could be `foo/bar.py:22:in` inputs which we want to match too. + // The regex mentioned is not very extendable with "digit or random string" checks, so do this here instead. + let delimiter = ':'; + let mut path_parts = s + .rsplitn(3, delimiter) + .collect::>() + .into_iter() + .rev() + .fuse(); + let mut path_string = path_parts.next().expect("rsplitn should have the rest of the string as its last parameter that we reversed").to_owned(); + let mut row = None; + let mut column = None; + if let Some(maybe_row) = path_parts.next() { + if let Ok(parsed_row) = maybe_row.parse::() { + row = Some(parsed_row); + if let Some(parsed_column) = path_parts + .next() + .and_then(|maybe_col| maybe_col.parse::().ok()) + { + column = Some(parsed_column); + } + } else { + path_string.push(delimiter); + path_string.push_str(maybe_row); + } + } + for split in path_parts { + path_string.push(delimiter); + path_string.push_str(split); + } + + Self { + path: PathBuf::from(path_string), + row, + column, + } + } + } + } + + pub fn map_path( + self, + mapping: impl FnOnce(PathBuf) -> Result, + ) -> Result { + Ok(PathWithPosition { + path: mapping(self.path)?, + row: self.row, + column: self.column, + }) + } + + pub fn to_string(&self, path_to_string: &dyn Fn(&PathBuf) -> String) -> String { + let path_string = path_to_string(&self.path); + if let Some(row) = self.row { + if let Some(column) = self.column { + format!("{path_string}:{row}:{column}") + } else { + format!("{path_string}:{row}") + } + } else { + path_string + } + } +} + +#[derive(Clone)] +pub struct PathMatcher { + sources: Vec<(String, RelPathBuf, /*trailing separator*/ bool)>, + glob: GlobSet, + path_style: PathStyle, +} + +impl std::fmt::Debug for PathMatcher { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PathMatcher") + .field("sources", &self.sources) + .field("path_style", &self.path_style) + .finish() + } +} + +impl PartialEq for PathMatcher { + fn eq(&self, other: &Self) -> bool { + self.sources.eq(&other.sources) + } +} + +impl Eq for PathMatcher {} + +impl PathMatcher { + pub fn new( + globs: impl IntoIterator>, + path_style: PathStyle, + ) -> Result { + let globs = globs + .into_iter() + .map(|as_str| { + GlobBuilder::new(as_str.as_ref()) + .backslash_escape(path_style.is_posix()) + .build() + }) + .collect::, _>>()?; + Self::from_globs(globs, path_style) + } + + /// Skips invalid globs, reporting each error to `on_error`. + /// If the combined set cannot be built, reports the error and matches nothing. + pub fn new_lenient( + globs: impl IntoIterator>, + path_style: PathStyle, + mut on_error: impl FnMut(globset::Error), + ) -> Self { + let globs = globs + .into_iter() + .filter_map(|pattern| { + match GlobBuilder::new(pattern.as_ref()) + .backslash_escape(path_style.is_posix()) + .build() + { + Ok(glob) => Some(glob), + Err(error) => { + on_error(error); + None + } + } + }) + .collect(); + match Self::from_globs(globs, path_style) { + Ok(matcher) => matcher, + Err(error) => { + on_error(error); + Self { + path_style, + ..Self::default() + } + } + } + } + + fn from_globs(globs: Vec, path_style: PathStyle) -> Result { + let sources = globs + .iter() + .filter_map(|glob| { + let glob = glob.glob(); + Some(( + glob.to_string(), + RelPath::new(&glob.as_ref(), path_style) + .ok() + .map(std::borrow::Cow::into_owned)?, + glob.ends_with(path_style.separators_ch()), + )) + }) + .collect(); + let mut glob_builder = GlobSetBuilder::new(); + for single_glob in globs { + glob_builder.add(single_glob); + } + let glob = glob_builder.build()?; + Ok(PathMatcher { + glob, + sources, + path_style, + }) + } + + pub fn sources(&self) -> impl Iterator + Clone { + self.sources.iter().map(|(source, ..)| source.as_str()) + } + + pub fn is_match>(&self, other: P) -> bool { + let other = other.as_ref(); + if self + .sources + .iter() + .any(|(_, source, _)| other.starts_with(source) || other.ends_with(source)) + { + return true; + } + let other_path = other.display(self.path_style); + + if self.glob.is_match(&*other_path) { + return true; + } + + self.glob + .is_match(other_path.into_owned() + self.path_style.primary_separator()) + } + + pub fn is_match_std_path>(&self, other: P) -> bool { + let other = other.as_ref(); + if self.sources.iter().any(|(_, source, _)| { + other.starts_with(source.as_std_path()) || other.ends_with(source.as_std_path()) + }) { + return true; + } + self.glob.is_match(other) + } +} + +impl Default for PathMatcher { + fn default() -> Self { + Self { + path_style: PathStyle::local(), + glob: GlobSet::empty(), + sources: vec![], + } + } +} + +/// Compares two sequences of consecutive digits for natural sorting. +/// +/// This function is a core component of natural sorting that handles numeric comparison +/// in a way that feels natural to humans. It extracts and compares consecutive digit +/// sequences from two iterators, handling various cases like leading zeros and very large numbers. +/// +/// # Behavior +/// +/// The function implements the following comparison rules: +/// 1. Different numeric values: Compares by actual numeric value (e.g., "2" < "10") +/// 2. Leading zeros: When values are equal, longer sequence wins (e.g., "002" > "2") +/// 3. Large numbers: Falls back to string comparison for numbers that would overflow u128 +/// +/// # Examples +/// +/// ```text +/// "1" vs "2" -> Less (different values) +/// "2" vs "10" -> Less (numeric comparison) +/// "002" vs "2" -> Greater (leading zeros) +/// "10" vs "010" -> Less (leading zeros) +/// "999..." vs "1000..." -> Less (large number comparison) +/// ``` +/// +/// # Implementation Details +/// +/// 1. Extracts consecutive digits into strings +/// 2. Compares sequence lengths for leading zero handling +/// 3. For equal lengths, compares digit by digit +/// 4. For different lengths: +/// - Attempts numeric comparison first (for numbers up to 2^128 - 1) +/// - Falls back to string comparison if numbers would overflow +/// +/// The function advances both iterators past their respective numeric sequences, +/// regardless of the comparison result. +fn compare_numeric_segments( + a_iter: &mut std::iter::Peekable, + b_iter: &mut std::iter::Peekable, +) -> Ordering +where + I: Iterator, +{ + // Collect all consecutive digits into strings + let mut a_num_str = String::new(); + let mut b_num_str = String::new(); + + while let Some(&c) = a_iter.peek() { + if !c.is_ascii_digit() { + break; + } + + a_num_str.push(c); + a_iter.next(); + } + + while let Some(&c) = b_iter.peek() { + if !c.is_ascii_digit() { + break; + } + + b_num_str.push(c); + b_iter.next(); + } + + // First compare lengths (handle leading zeros) + match a_num_str.len().cmp(&b_num_str.len()) { + Ordering::Equal => { + // Same length, compare digit by digit + match a_num_str.cmp(&b_num_str) { + Ordering::Equal => Ordering::Equal, + ordering => ordering, + } + } + + // Different lengths but same value means leading zeros + ordering => { + // Try parsing as numbers first + if let (Ok(a_val), Ok(b_val)) = (a_num_str.parse::(), b_num_str.parse::()) { + match a_val.cmp(&b_val) { + Ordering::Equal => ordering, // Same value, longer one is greater (leading zeros) + ord => ord, + } + } else { + // If parsing fails (overflow), compare as strings + a_num_str.cmp(&b_num_str) + } + } + } +} + +/// Performs natural sorting comparison between two strings. +/// +/// Natural sorting is an ordering that handles numeric sequences in a way that matches human expectations. +/// For example, "file2" comes before "file10" (unlike standard lexicographic sorting). +/// +/// # Characteristics +/// +/// * Case-sensitive with lowercase priority: When comparing same letters, lowercase comes before uppercase +/// * Numbers are compared by numeric value, not character by character +/// * Leading zeros affect ordering when numeric values are equal +/// * Can handle numbers larger than u128::MAX (falls back to string comparison) +/// * When strings are equal case-insensitively, lowercase is prioritized (lowercase < uppercase) +/// +/// # Algorithm +/// +/// The function works by: +/// 1. Processing strings character by character in a case-insensitive manner +/// 2. When encountering digits, treating consecutive digits as a single number +/// 3. Comparing numbers by their numeric value rather than lexicographically +/// 4. For non-numeric characters, using case-insensitive comparison +/// 5. If everything is equal case-insensitively, using case-sensitive comparison as final tie-breaker +pub fn natural_sort(a: &str, b: &str) -> Ordering { + let mut a_iter = a.chars().peekable(); + let mut b_iter = b.chars().peekable(); + + loop { + match (a_iter.peek(), b_iter.peek()) { + (None, None) => { + return b.cmp(a); + } + (None, _) => return Ordering::Less, + (_, None) => return Ordering::Greater, + (Some(&a_char), Some(&b_char)) => { + if a_char.is_ascii_digit() && b_char.is_ascii_digit() { + match compare_numeric_segments(&mut a_iter, &mut b_iter) { + Ordering::Equal => continue, + ordering => return ordering, + } + } else { + match a_char + .to_ascii_lowercase() + .cmp(&b_char.to_ascii_lowercase()) + { + Ordering::Equal => { + a_iter.next(); + b_iter.next(); + } + ordering => return ordering, + } + } + } + } + } +} + +/// Case-insensitive natural sort without applying the final lowercase/uppercase tie-breaker. +/// This is useful when comparing individual path components where we want to keep walking +/// deeper components before deciding on casing. +fn natural_sort_no_tiebreak(a: &str, b: &str) -> Ordering { + if a.eq_ignore_ascii_case(b) { + Ordering::Equal + } else { + natural_sort(a, b) + } +} + +fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { + if filename.is_empty() { + return (None, None); + } + + match filename.rsplit_once('.') { + // Case 1: No dot was found. The entire name is the stem. + None => (Some(filename), None), + + // Case 2: A dot was found. + Some((before, after)) => { + // This is the crucial check for dotfiles like ".bashrc". + // If `before` is empty, the dot was the first character. + // In that case, we revert to the "whole name is the stem" logic. + if before.is_empty() { + (Some(filename), None) + } else { + // Otherwise, we have a standard stem and extension. + (Some(before), Some(after)) + } + } + } +} + +/// Controls the lexicographic sorting of file and folder names. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SortOrder { + /// Case-insensitive natural sort with lowercase preferred in ties. + /// Numbers in file names are compared by value (e.g., `file2` before `file10`). + #[default] + Default, + /// Uppercase names are grouped before lowercase names, with case-insensitive + /// natural sort within each group. Dot-prefixed names sort before both groups. + Upper, + /// Lowercase names are grouped before uppercase names, with case-insensitive + /// natural sort within each group. Dot-prefixed names sort before both groups. + Lower, + /// Pure Unicode codepoint comparison. No case folding, no natural number sorting. + /// Uppercase ASCII sorts before lowercase. Accented characters sort after ASCII. + Unicode, +} + +/// Controls how files and directories are ordered relative to each other. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SortMode { + /// Directories are listed before files at each level. + #[default] + DirectoriesFirst, + /// Files and directories are interleaved alphabetically. + Mixed, + /// Files are listed before directories at each level. + FilesFirst, +} + +fn case_group_key(name: &str, order: SortOrder) -> u8 { + let first = match name.chars().next() { + Some(c) => c, + None => return 0, + }; + match order { + SortOrder::Upper => { + if first.is_lowercase() { + 1 + } else { + 0 + } + } + SortOrder::Lower => { + if first.is_uppercase() { + 1 + } else { + 0 + } + } + _ => 0, + } +} + +fn compare_strings(a: &str, b: &str, order: SortOrder) -> Ordering { + match order { + SortOrder::Unicode => a.cmp(b), + _ => natural_sort(a, b), + } +} + +fn compare_strings_no_tiebreak(a: &str, b: &str, order: SortOrder) -> Ordering { + match order { + SortOrder::Unicode => a.cmp(b), + _ => natural_sort_no_tiebreak(a, b), + } +} + +pub fn compare_rel_paths( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), +) -> Ordering { + compare_rel_paths_by( + (path_a, a_is_file), + (path_b, b_is_file), + SortMode::DirectoriesFirst, + SortOrder::Default, + ) +} + +pub fn compare_rel_paths_by( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), + mode: SortMode, + order: SortOrder, +) -> Ordering { + let needs_final_tiebreak = + mode != SortMode::DirectoriesFirst && !(std::ptr::eq(path_a, path_b) || path_a == path_b); + + let mut components_a = path_a.components(); + let mut components_b = path_b.components(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_leaf_file = a_is_file && components_a.rest().is_empty(); + let b_leaf_file = b_is_file && components_b.rest().is_empty(); + + let file_dir_ordering = match mode { + SortMode::DirectoriesFirst => a_leaf_file.cmp(&b_leaf_file), + SortMode::FilesFirst => b_leaf_file.cmp(&a_leaf_file), + SortMode::Mixed => Ordering::Equal, + }; + + if !file_dir_ordering.is_eq() { + return file_dir_ordering; + } + + let (a_stem, a_ext) = a_leaf_file + .then(|| stem_and_extension(component_a)) + .unwrap_or_default(); + let (b_stem, b_ext) = b_leaf_file + .then(|| stem_and_extension(component_b)) + .unwrap_or_default(); + let a_key = if a_leaf_file { + a_stem + } else { + Some(component_a) + }; + let b_key = if b_leaf_file { + b_stem + } else { + Some(component_b) + }; + + let ordering = match (a_key, b_key) { + (Some(a), Some(b)) => { + let name_cmp = case_group_key(a, order) + .cmp(&case_group_key(b, order)) + .then_with(|| match mode { + SortMode::DirectoriesFirst => compare_strings(a, b, order), + _ => compare_strings_no_tiebreak(a, b, order), + }); + + let name_cmp = if mode == SortMode::Mixed { + name_cmp.then_with(|| match (a_leaf_file, b_leaf_file) { + (true, false) if a.eq_ignore_ascii_case(b) => Ordering::Greater, + (false, true) if a.eq_ignore_ascii_case(b) => Ordering::Less, + _ => Ordering::Equal, + }) + } else { + name_cmp + }; + + name_cmp.then_with(|| { + if a_leaf_file && b_leaf_file { + match order { + SortOrder::Unicode => { + a_ext.unwrap_or_default().cmp(b_ext.unwrap_or_default()) + } + _ => { + let a_ext_str = a_ext.unwrap_or_default().to_lowercase(); + let b_ext_str = b_ext.unwrap_or_default().to_lowercase(); + a_ext_str.cmp(&b_ext_str) + } + } + } else { + Ordering::Equal + } + }) + } + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => { + if needs_final_tiebreak { + return compare_strings(path_a.as_unix_str(), path_b.as_unix_str(), order); + } + return Ordering::Equal; + } + } + } +} + +pub fn compare_paths( + (path_a, a_is_file): (&Path, bool), + (path_b, b_is_file): (&Path, bool), +) -> Ordering { + let mut components_a = path_a.components().peekable(); + let mut components_b = path_b.components().peekable(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_is_file = components_a.peek().is_none() && a_is_file; + let b_is_file = components_b.peek().is_none() && b_is_file; + + let ordering = a_is_file.cmp(&b_is_file).then_with(|| { + let path_a = Path::new(component_a.as_os_str()); + let path_string_a = if a_is_file { + path_a.file_stem() + } else { + path_a.file_name() + } + .map(|s| s.to_string_lossy()); + + let path_b = Path::new(component_b.as_os_str()); + let path_string_b = if b_is_file { + path_b.file_stem() + } else { + path_b.file_name() + } + .map(|s| s.to_string_lossy()); + + let compare_components = match (path_string_a, path_string_b) { + (Some(a), Some(b)) => natural_sort(&a, &b), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + compare_components.then_with(|| { + if a_is_file && b_is_file { + let ext_a = path_a.extension().unwrap_or_default(); + let ext_b = path_b.extension().unwrap_or_default(); + ext_a.cmp(ext_b) + } else { + Ordering::Equal + } + }) + }); + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => break Ordering::Greater, + (None, Some(_)) => break Ordering::Less, + (None, None) => break Ordering::Equal, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WslPath { + pub distro: String, + + // the reason this is an OsString and not any of the path types is that it needs to + // represent a unix path (with '/' separators) on windows. `from_path` does this by + // manually constructing it from the path components of a given windows path. + pub path: std::ffi::OsString, +} + +impl WslPath { + pub fn from_path>(path: P) -> Option { + if cfg!(not(target_os = "windows")) { + return None; + } + use std::{ + ffi::OsString, + path::{Component, Prefix}, + }; + + let mut components = path.as_ref().components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return None; + }; + let (server, distro) = match prefix.kind() { + Prefix::UNC(server, distro) => (server, distro), + Prefix::VerbatimUNC(server, distro) => (server, distro), + _ => return None, + }; + let Some(Component::RootDir) = components.next() else { + return None; + }; + + let server_str = server.to_string_lossy(); + if server_str == "wsl.localhost" || server_str == "wsl$" { + let mut result = OsString::from(""); + for c in components { + use Component::*; + match c { + Prefix(p) => unreachable!("got {p:?}, but already stripped prefix"), + RootDir => unreachable!("got root dir, but already stripped root"), + CurDir => continue, + ParentDir => result.push("/.."), + Normal(s) => { + result.push("/"); + result.push(s); + } + } + } + if result.is_empty() { + result.push("/"); + } + Some(WslPath { + distro: distro.to_string_lossy().to_string(), + path: result, + }) + } else { + None + } + } +} + +pub trait UrlExt { + /// A version of `url::Url::to_file_path` that does platform handling based on the provided `PathStyle` instead of the host platform. + /// + /// Prefer using this over `url::Url::to_file_path` when you need to handle paths in a cross-platform way as is the case for remoting interactions. + fn to_file_path_ext(&self, path_style: PathStyle) -> Result; +} + +impl UrlExt for url::Url { + // Copied from `url::Url::to_file_path`, but the `cfg` handling is replaced with runtime branching on `PathStyle` + fn to_file_path_ext(&self, source_path_style: PathStyle) -> Result { + if let Some(segments) = self.path_segments() { + let host = match self.host() { + None | Some(url::Host::Domain("localhost")) => None, + Some(_) if source_path_style.is_windows() && self.scheme() == "file" => { + self.host_str() + } + _ => return Err(()), + }; + + let str_len = self.as_str().len(); + let estimated_capacity = if source_path_style.is_windows() { + // remove scheme: - has possible \\ for hostname + str_len.saturating_sub(self.scheme().len() + 1) + } else { + // remove scheme:// + str_len.saturating_sub(self.scheme().len() + 3) + }; + return match source_path_style { + PathStyle::Unix => { + file_url_segments_to_pathbuf_posix(estimated_capacity, host, segments) + } + PathStyle::Windows => { + file_url_segments_to_pathbuf_windows(estimated_capacity, host, segments) + } + }; + } + + fn file_url_segments_to_pathbuf_posix( + estimated_capacity: usize, + host: Option<&str>, + segments: std::str::Split<'_, char>, + ) -> Result { + use percent_encoding::percent_decode; + + if host.is_some() { + return Err(()); + } + + let mut bytes = Vec::new(); + bytes.try_reserve(estimated_capacity).map_err(|_| ())?; + + for segment in segments { + bytes.push(b'/'); + bytes.extend(percent_decode(segment.as_bytes())); + } + + // A windows drive letter must end with a slash. + if bytes.len() > 2 + && bytes[bytes.len() - 2].is_ascii_alphabetic() + && matches!(bytes[bytes.len() - 1], b':' | b'|') + { + bytes.push(b'/'); + } + + let path = String::from_utf8(bytes).map_err(|_| ())?; + debug_assert!( + PathStyle::Unix.is_absolute(&path), + "to_file_path() failed to produce an absolute Path" + ); + + Ok(PathBuf::from(path)) + } + + fn file_url_segments_to_pathbuf_windows( + estimated_capacity: usize, + host: Option<&str>, + mut segments: std::str::Split<'_, char>, + ) -> Result { + use percent_encoding::percent_decode_str; + let mut string = String::new(); + string.try_reserve(estimated_capacity).map_err(|_| ())?; + if let Some(host) = host { + string.push_str(r"\\"); + string.push_str(host); + } else { + let first = segments.next().ok_or(())?; + + match first.len() { + 2 => { + if !first.starts_with(|c| char::is_ascii_alphabetic(&c)) + || first.as_bytes()[1] != b':' + { + return Err(()); + } + + string.push_str(first); + } + + 4 => { + if !first.starts_with(|c| char::is_ascii_alphabetic(&c)) { + return Err(()); + } + let bytes = first.as_bytes(); + if bytes[1] != b'%' + || bytes[2] != b'3' + || (bytes[3] != b'a' && bytes[3] != b'A') + { + return Err(()); + } + + string.push_str(&first[0..1]); + string.push(':'); + } + + _ => return Err(()), + } + }; + + for segment in segments { + string.push('\\'); + + // Currently non-unicode windows paths cannot be represented + match percent_decode_str(segment).decode_utf8() { + Ok(s) => string.push_str(&s), + Err(..) => return Err(()), + } + } + // ensure our estimated capacity was good + if cfg!(test) { + debug_assert!( + string.len() <= estimated_capacity, + "len: {}, capacity: {}", + string.len(), + estimated_capacity + ); + } + debug_assert!( + PathStyle::Windows.is_absolute(&string), + "to_file_path() failed to produce an absolute Path" + ); + let path = PathBuf::from(string); + Ok(path) + } + Err(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use util_macros::perf; + + #[test] + fn test_lenient_path_matcher() { + for path_style in [PathStyle::Unix, PathStyle::Windows] { + let patterns = ["**/.git", "[", "target/**", "{"]; + let mut errors = Vec::new(); + let matcher = + PathMatcher::new_lenient(patterns, path_style, |error| errors.push(error)); + let expected = PathMatcher::new(["**/.git", "target/**"], path_style).unwrap(); + assert_eq!(matcher, expected); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].glob(), Some("[")); + assert_eq!(errors[1].glob(), Some("{")); + for path in ["nested/.git", "src/file.rs", "target/file.rs"] { + let path = RelPath::from_unix_str(path).unwrap(); + assert_eq!(matcher.is_match(path), expected.is_match(path)); + } + if path_style == PathStyle::local() { + assert!(matcher.is_match(RelPath::from_unix_str("nested/.git").unwrap())); + assert!(matcher.is_match(RelPath::from_unix_str("target/file.rs").unwrap())); + } + assert!(PathMatcher::new(patterns, path_style).is_err()); + + let mut errors = Vec::new(); + let matcher = + PathMatcher::new_lenient(["[", "{"], path_style, |error| errors.push(error)); + assert_eq!(errors.len(), 2); + assert_eq!(matcher.path_style, path_style); + assert_eq!(matcher.sources().count(), 0); + assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap())); + assert!(!matcher.is_match_std_path("file.rs")); + + let matcher = PathMatcher::new_lenient([] as [&str; 0], path_style, |_| { + panic!("empty patterns are valid") + }); + assert_eq!(matcher.path_style, path_style); + assert_eq!(matcher.sources().count(), 0); + assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap())); + } + } + + #[test] + fn test_lenient_path_matcher_preserves_escaping() { + for path_style in [PathStyle::Unix, PathStyle::Windows] { + let patterns = [r"directory\file.rs", r"literal\*", r"literal\[name]"]; + let strict = PathMatcher::new(patterns, path_style).unwrap(); + let lenient = PathMatcher::new_lenient(patterns, path_style, |_| { + panic!("valid patterns are preserved") + }); + assert_eq!(lenient, strict); + for path in [ + "directory/file.rs", + "literal*", + "literal/file.rs", + "literal[name]", + ] { + let path = RelPath::from_unix_str(path).unwrap(); + assert_eq!(lenient.is_match(path), strict.is_match(path)); + } + } + } + + #[test] + fn test_parse_str_treats_paren_suffix_as_position() { + // This documents the behavior that causes the folder-drop bug: a name ending in + // `(N)` is parsed as `name ` + row N. The fix lives in `derive_paths_with_position`, + // which restores the original path when it exists on disk (file or directory). + let parsed = PathWithPosition::parse_str("/root/Test (3)"); + assert_eq!(parsed.path, PathBuf::from("/root/Test ")); + assert_eq!(parsed.row, Some(3)); + } + + fn rel_path_entry(path: &'static str, is_file: bool) -> (&'static RelPath, bool) { + (RelPath::from_unix_str(path).unwrap(), is_file) + } + + fn sorted_rel_paths( + mut paths: Vec<(&'static RelPath, bool)>, + mode: SortMode, + order: SortOrder, + ) -> Vec<(&'static RelPath, bool)> { + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, mode, order)); + paths + } + + #[perf] + fn compare_paths_with_dots() { + let mut paths = vec![ + (Path::new("test_dirs"), false), + (Path::new("test_dirs/1.46"), false), + (Path::new("test_dirs/1.46/bar_1"), true), + (Path::new("test_dirs/1.46/bar_2"), true), + (Path::new("test_dirs/1.45"), false), + (Path::new("test_dirs/1.45/foo_2"), true), + (Path::new("test_dirs/1.45/foo_1"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("test_dirs"), false), + (Path::new("test_dirs/1.45"), false), + (Path::new("test_dirs/1.45/foo_1"), true), + (Path::new("test_dirs/1.45/foo_2"), true), + (Path::new("test_dirs/1.46"), false), + (Path::new("test_dirs/1.46/bar_1"), true), + (Path::new("test_dirs/1.46/bar_2"), true), + ] + ); + let mut paths = vec![ + (Path::new("root1/one.txt"), true), + (Path::new("root1/one.two.txt"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("root1/one.txt"), true), + (Path::new("root1/one.two.txt"), true), + ] + ); + } + + #[perf] + fn compare_paths_with_same_name_different_extensions() { + let mut paths = vec![ + (Path::new("test_dirs/file.rs"), true), + (Path::new("test_dirs/file.txt"), true), + (Path::new("test_dirs/file.md"), true), + (Path::new("test_dirs/file"), true), + (Path::new("test_dirs/file.a"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("test_dirs/file"), true), + (Path::new("test_dirs/file.a"), true), + (Path::new("test_dirs/file.md"), true), + (Path::new("test_dirs/file.rs"), true), + (Path::new("test_dirs/file.txt"), true), + ] + ); + } + + #[perf] + fn compare_paths_case_semi_sensitive() { + let mut paths = vec![ + (Path::new("test_DIRS"), false), + (Path::new("test_DIRS/foo_1"), true), + (Path::new("test_DIRS/foo_2"), true), + (Path::new("test_DIRS/bar"), true), + (Path::new("test_DIRS/BAR"), true), + (Path::new("test_dirs"), false), + (Path::new("test_dirs/foo_1"), true), + (Path::new("test_dirs/foo_2"), true), + (Path::new("test_dirs/bar"), true), + (Path::new("test_dirs/BAR"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("test_dirs"), false), + (Path::new("test_dirs/bar"), true), + (Path::new("test_dirs/BAR"), true), + (Path::new("test_dirs/foo_1"), true), + (Path::new("test_dirs/foo_2"), true), + (Path::new("test_DIRS"), false), + (Path::new("test_DIRS/bar"), true), + (Path::new("test_DIRS/BAR"), true), + (Path::new("test_DIRS/foo_1"), true), + (Path::new("test_DIRS/foo_2"), true), + ] + ); + } + + #[perf] + fn compare_paths_mixed_case_numeric_ordering() { + let mut entries = [ + (Path::new(".config"), false), + (Path::new("Dir1"), false), + (Path::new("dir01"), false), + (Path::new("dir2"), false), + (Path::new("Dir02"), false), + (Path::new("dir10"), false), + (Path::new("Dir10"), false), + ]; + + entries.sort_by(|&a, &b| compare_paths(a, b)); + + let ordered: Vec<&str> = entries + .iter() + .map(|(path, _)| path.to_str().unwrap()) + .collect(); + + assert_eq!( + ordered, + vec![ + ".config", "Dir1", "dir01", "dir2", "Dir02", "dir10", "Dir10" + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_case_insensitive() { + // Test that mixed mode is case-insensitive + let mut paths = vec![ + (RelPath::from_unix_str("zebra.txt").unwrap(), true), + (RelPath::from_unix_str("Apple").unwrap(), false), + (RelPath::from_unix_str("banana.rs").unwrap(), true), + (RelPath::from_unix_str("Carrot").unwrap(), false), + (RelPath::from_unix_str("aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + // Case-insensitive: aardvark < Apple < banana < Carrot < zebra + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("aardvark.txt").unwrap(), true), + (RelPath::from_unix_str("Apple").unwrap(), false), + (RelPath::from_unix_str("banana.rs").unwrap(), true), + (RelPath::from_unix_str("Carrot").unwrap(), false), + (RelPath::from_unix_str("zebra.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_basic() { + // Test that files come before directories + let mut paths = vec![ + (RelPath::from_unix_str("zebra.txt").unwrap(), true), + (RelPath::from_unix_str("Apple").unwrap(), false), + (RelPath::from_unix_str("banana.rs").unwrap(), true), + (RelPath::from_unix_str("Carrot").unwrap(), false), + (RelPath::from_unix_str("aardvark.txt").unwrap(), true), + ]; + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); + // Files first (case-insensitive), then directories (case-insensitive) + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("aardvark.txt").unwrap(), true), + (RelPath::from_unix_str("banana.rs").unwrap(), true), + (RelPath::from_unix_str("zebra.txt").unwrap(), true), + (RelPath::from_unix_str("Apple").unwrap(), false), + (RelPath::from_unix_str("Carrot").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_case_insensitive() { + // Test case-insensitive sorting within files and directories + let mut paths = vec![ + (RelPath::from_unix_str("Zebra.txt").unwrap(), true), + (RelPath::from_unix_str("apple").unwrap(), false), + (RelPath::from_unix_str("Banana.rs").unwrap(), true), + (RelPath::from_unix_str("carrot").unwrap(), false), + (RelPath::from_unix_str("Aardvark.txt").unwrap(), true), + ]; + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("Aardvark.txt").unwrap(), true), + (RelPath::from_unix_str("Banana.rs").unwrap(), true), + (RelPath::from_unix_str("Zebra.txt").unwrap(), true), + (RelPath::from_unix_str("apple").unwrap(), false), + (RelPath::from_unix_str("carrot").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_numeric() { + // Test natural number sorting with files first + let mut paths = vec![ + (RelPath::from_unix_str("file10.txt").unwrap(), true), + (RelPath::from_unix_str("dir2").unwrap(), false), + (RelPath::from_unix_str("file2.txt").unwrap(), true), + (RelPath::from_unix_str("dir10").unwrap(), false), + (RelPath::from_unix_str("file1.txt").unwrap(), true), + ]; + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("file1.txt").unwrap(), true), + (RelPath::from_unix_str("file2.txt").unwrap(), true), + (RelPath::from_unix_str("file10.txt").unwrap(), true), + (RelPath::from_unix_str("dir2").unwrap(), false), + (RelPath::from_unix_str("dir10").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_case() { + // Test case-insensitive sorting with varied capitalization + let mut paths = vec![ + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str("readme.txt").unwrap(), true), + (RelPath::from_unix_str("ReadMe.rs").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + // All "readme" variants should group together, sorted by extension + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str("ReadMe.rs").unwrap(), true), + (RelPath::from_unix_str("readme.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_files_and_dirs() { + // Verify directories and files are still mixed + let mut paths = vec![ + (RelPath::from_unix_str("file2.txt").unwrap(), true), + (RelPath::from_unix_str("Dir1").unwrap(), false), + (RelPath::from_unix_str("file1.txt").unwrap(), true), + (RelPath::from_unix_str("dir2").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + // Case-insensitive: dir1, dir2, file1, file2 (all mixed) + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("Dir1").unwrap(), false), + (RelPath::from_unix_str("dir2").unwrap(), false), + (RelPath::from_unix_str("file1.txt").unwrap(), true), + (RelPath::from_unix_str("file2.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_same_name_different_case_file_and_dir() { + let mut paths = vec![ + (RelPath::from_unix_str("Hello.txt").unwrap(), true), + (RelPath::from_unix_str("hello").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("hello").unwrap(), false), + (RelPath::from_unix_str("Hello.txt").unwrap(), true), + ] + ); + + let mut paths = vec![ + (RelPath::from_unix_str("hello").unwrap(), false), + (RelPath::from_unix_str("Hello.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("hello").unwrap(), false), + (RelPath::from_unix_str("Hello.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_with_nested_paths() { + // Test that nested paths still work correctly + let mut paths = vec![ + (RelPath::from_unix_str("src/main.rs").unwrap(), true), + (RelPath::from_unix_str("Cargo.toml").unwrap(), true), + (RelPath::from_unix_str("src").unwrap(), false), + (RelPath::from_unix_str("target").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("Cargo.toml").unwrap(), true), + (RelPath::from_unix_str("src").unwrap(), false), + (RelPath::from_unix_str("src/main.rs").unwrap(), true), + (RelPath::from_unix_str("target").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_with_nested() { + // Files come before directories, even with nested paths + let mut paths = vec![ + (RelPath::from_unix_str("src/lib.rs").unwrap(), true), + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str("src").unwrap(), false), + (RelPath::from_unix_str("tests").unwrap(), false), + ]; + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str("src").unwrap(), false), + (RelPath::from_unix_str("src/lib.rs").unwrap(), true), + (RelPath::from_unix_str("tests").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_dotfiles() { + // Test that dotfiles are handled correctly in mixed mode + let mut paths = vec![ + (RelPath::from_unix_str(".gitignore").unwrap(), true), + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str(".github").unwrap(), false), + (RelPath::from_unix_str("src").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str(".github").unwrap(), false), + (RelPath::from_unix_str(".gitignore").unwrap(), true), + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str("src").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_dotfiles() { + // Test that dotfiles come first when they're files + let mut paths = vec![ + (RelPath::from_unix_str(".gitignore").unwrap(), true), + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str(".github").unwrap(), false), + (RelPath::from_unix_str("src").unwrap(), false), + ]; + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str(".gitignore").unwrap(), true), + (RelPath::from_unix_str("README.md").unwrap(), true), + (RelPath::from_unix_str(".github").unwrap(), false), + (RelPath::from_unix_str("src").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_same_stem_different_extension() { + // Files with same stem but different extensions should sort by extension + let mut paths = vec![ + (RelPath::from_unix_str("file.rs").unwrap(), true), + (RelPath::from_unix_str("file.md").unwrap(), true), + (RelPath::from_unix_str("file.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("file.md").unwrap(), true), + (RelPath::from_unix_str("file.rs").unwrap(), true), + (RelPath::from_unix_str("file.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_same_stem() { + // Same stem files should still sort by extension with files_first + let mut paths = vec![ + (RelPath::from_unix_str("main.rs").unwrap(), true), + (RelPath::from_unix_str("main.c").unwrap(), true), + (RelPath::from_unix_str("main").unwrap(), false), + ]; + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("main.c").unwrap(), true), + (RelPath::from_unix_str("main.rs").unwrap(), true), + (RelPath::from_unix_str("main").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_deep_nesting() { + // Test sorting with deeply nested paths + let mut paths = vec![ + (RelPath::from_unix_str("a/b/c.txt").unwrap(), true), + (RelPath::from_unix_str("A/B.txt").unwrap(), true), + (RelPath::from_unix_str("a.txt").unwrap(), true), + (RelPath::from_unix_str("A.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); + assert_eq!( + paths, + vec![ + (RelPath::from_unix_str("a/b/c.txt").unwrap(), true), + (RelPath::from_unix_str("A/B.txt").unwrap(), true), + (RelPath::from_unix_str("a.txt").unwrap(), true), + (RelPath::from_unix_str("A.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_upper() { + let directories_only_paths = vec![ + rel_path_entry("mixedCase", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("dog", false), + rel_path_entry(".hidden", false), + rel_path_entry("Carrot", false), + ]; + assert_eq!( + sorted_rel_paths( + directories_only_paths, + SortMode::DirectoriesFirst, + SortOrder::Upper, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("Carrot", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("dog", false), + rel_path_entry("mixedCase", false), + ] + ); + + let file_and_directory_paths = vec![ + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("file1.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + ]; + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::DirectoriesFirst, + SortOrder::Upper, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::Mixed, + SortOrder::Upper, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("banana", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths, + SortMode::FilesFirst, + SortOrder::Upper, + ), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + ] + ); + + let natural_sort_paths = vec![ + rel_path_entry("file10.txt", true), + rel_path_entry("file1.txt", true), + rel_path_entry("file20.txt", true), + rel_path_entry("file2.txt", true), + ]; + assert_eq!( + sorted_rel_paths(natural_sort_paths, SortMode::Mixed, SortOrder::Upper,), + vec![ + rel_path_entry("file1.txt", true), + rel_path_entry("file2.txt", true), + rel_path_entry("file10.txt", true), + rel_path_entry("file20.txt", true), + ] + ); + + let accented_paths = vec![ + rel_path_entry("\u{00C9}something.txt", true), + rel_path_entry("zebra.txt", true), + rel_path_entry("Apple.txt", true), + ]; + assert_eq!( + sorted_rel_paths(accented_paths, SortMode::Mixed, SortOrder::Upper), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("\u{00C9}something.txt", true), + rel_path_entry("zebra.txt", true), + ] + ); + } + + #[perf] + fn compare_rel_paths_lower() { + let directories_only_paths = vec![ + rel_path_entry("mixedCase", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("dog", false), + rel_path_entry(".hidden", false), + rel_path_entry("Carrot", false), + ]; + assert_eq!( + sorted_rel_paths( + directories_only_paths, + SortMode::DirectoriesFirst, + SortOrder::Lower, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("dog", false), + rel_path_entry("mixedCase", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("Carrot", false), + rel_path_entry("Zebra", false), + ] + ); + + let file_and_directory_paths = vec![ + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("file1.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + ]; + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::DirectoriesFirst, + SortOrder::Lower, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::Mixed, + SortOrder::Lower, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths, + SortMode::FilesFirst, + SortOrder::Lower, + ), + vec![ + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + ] + ); + } + + #[perf] + fn compare_rel_paths_unicode() { + let directories_only_paths = vec![ + rel_path_entry("mixedCase", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("dog", false), + rel_path_entry(".hidden", false), + rel_path_entry("Carrot", false), + ]; + assert_eq!( + sorted_rel_paths( + directories_only_paths, + SortMode::DirectoriesFirst, + SortOrder::Unicode, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("Carrot", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("dog", false), + rel_path_entry("mixedCase", false), + ] + ); + + let file_and_directory_paths = vec![ + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("file1.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + ]; + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::DirectoriesFirst, + SortOrder::Unicode, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::Mixed, + SortOrder::Unicode, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("banana", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths, + SortMode::FilesFirst, + SortOrder::Unicode, + ), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + ] + ); + + let numeric_paths = vec![ + rel_path_entry("file10.txt", true), + rel_path_entry("file1.txt", true), + rel_path_entry("file2.txt", true), + rel_path_entry("file20.txt", true), + ]; + assert_eq!( + sorted_rel_paths(numeric_paths, SortMode::Mixed, SortOrder::Unicode,), + vec![ + rel_path_entry("file1.txt", true), + rel_path_entry("file10.txt", true), + rel_path_entry("file2.txt", true), + rel_path_entry("file20.txt", true), + ] + ); + + let accented_paths = vec![ + rel_path_entry("\u{00C9}something.txt", true), + rel_path_entry("zebra.txt", true), + rel_path_entry("Apple.txt", true), + ]; + assert_eq!( + sorted_rel_paths(accented_paths, SortMode::Mixed, SortOrder::Unicode), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("zebra.txt", true), + rel_path_entry("\u{00C9}something.txt", true), + ] + ); + } + + #[perf] + fn path_with_position_parse_posix_path() { + // Test POSIX filename edge cases + // Read more at https://en.wikipedia.org/wiki/Filename + assert_eq!( + PathWithPosition::parse_str("test_file"), + PathWithPosition { + path: PathBuf::from("test_file"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("a:bc:.zip:1"), + PathWithPosition { + path: PathBuf::from("a:bc:.zip"), + row: Some(1), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("one.second.zip:1"), + PathWithPosition { + path: PathBuf::from("one.second.zip"), + row: Some(1), + column: None + } + ); + + // Trim off trailing `:`s for otherwise valid input. + assert_eq!( + PathWithPosition::parse_str("test_file:10:1:"), + PathWithPosition { + path: PathBuf::from("test_file"), + row: Some(10), + column: Some(1) + } + ); + + assert_eq!( + PathWithPosition::parse_str("test_file.rs:"), + PathWithPosition { + path: PathBuf::from("test_file.rs"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("test_file.rs:1:"), + PathWithPosition { + path: PathBuf::from("test_file.rs"), + row: Some(1), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("ab\ncd"), + PathWithPosition { + path: PathBuf::from("ab\ncd"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("👋\nab"), + PathWithPosition { + path: PathBuf::from("👋\nab"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("Types.hs:(617,9)-(670,28):"), + PathWithPosition { + path: PathBuf::from("Types.hs"), + row: Some(617), + column: Some(9), + } + ); + + assert_eq!( + PathWithPosition::parse_str("main (1).log"), + PathWithPosition { + path: PathBuf::from("main (1).log"), + row: None, + column: None + } + ); + } + + #[perf] + #[cfg(not(target_os = "windows"))] + fn path_with_position_parse_posix_path_with_suffix() { + assert_eq!( + PathWithPosition::parse_str("foo/bar:34:in"), + PathWithPosition { + path: PathBuf::from("foo/bar"), + row: Some(34), + column: None, + } + ); + assert_eq!( + PathWithPosition::parse_str("foo/bar.rs:1902:::15:"), + PathWithPosition { + path: PathBuf::from("foo/bar.rs:1902"), + row: Some(15), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"), + PathWithPosition { + path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"), + row: Some(34), + column: None, + } + ); + + assert_eq!( + PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"), + PathWithPosition { + path: PathBuf::from("crates/file_finder/src/file_finder.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("crate/utils/src/test:today.log:34"), + PathWithPosition { + path: PathBuf::from("crate/utils/src/test:today.log"), + row: Some(34), + column: None, + } + ); + assert_eq!( + PathWithPosition::parse_str("/testing/out/src/file_finder.odin(7:15)"), + PathWithPosition { + path: PathBuf::from("/testing/out/src/file_finder.odin"), + row: Some(7), + column: Some(15), + } + ); + } + + #[perf] + #[cfg(target_os = "windows")] + fn path_with_position_parse_windows_path() { + assert_eq!( + PathWithPosition::parse_str("crates\\utils\\paths.rs"), + PathWithPosition { + path: PathBuf::from("crates\\utils\\paths.rs"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\main (1).log"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\main (1).log"), + row: None, + column: None + } + ); + } + + #[perf] + #[cfg(target_os = "windows")] + fn path_with_position_parse_windows_path_with_suffix() { + assert_eq!( + PathWithPosition::parse_str("crates\\utils\\paths.rs:101"), + PathWithPosition { + path: PathBuf::from("crates\\utils\\paths.rs"), + row: Some(101), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1), + column: Some(20) + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13) + } + ); + + // Trim off trailing `:`s for otherwise valid input. + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13) + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"), + row: Some(13), + column: Some(15) + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"), + row: Some(15), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: None, + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13):"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: None, + } + ); + + assert_eq!( + PathWithPosition::parse_str("crates/utils/paths.rs:101"), + PathWithPosition { + path: PathBuf::from("crates\\utils\\paths.rs"), + row: Some(101), + column: None, + } + ); + } + + #[perf] + fn test_path_compact() { + let path: PathBuf = [ + home_dir().to_string_lossy().into_owned(), + "some_file.txt".to_string(), + ] + .iter() + .collect(); + if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") { + assert_eq!(path.compact().to_str(), Some("~/some_file.txt")); + } else { + assert_eq!(path.compact().to_str(), path.to_str()); + } + } + + #[perf] + fn test_extension_or_hidden_file_name() { + // No dots in name + let path = Path::new("/a/b/c/file_name.rs"); + assert_eq!(path.extension_or_hidden_file_name(), Some("rs")); + + // Single dot in name + let path = Path::new("/a/b/c/file.name.rs"); + assert_eq!(path.extension_or_hidden_file_name(), Some("rs")); + + // Multiple dots in name + let path = Path::new("/a/b/c/long.file.name.rs"); + assert_eq!(path.extension_or_hidden_file_name(), Some("rs")); + + // Hidden file, no extension + let path = Path::new("/a/b/c/.gitignore"); + assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore")); + + // Hidden file, with extension + let path = Path::new("/a/b/c/.eslintrc.js"); + assert_eq!(path.extension_or_hidden_file_name(), Some("eslintrc.js")); + } + + #[perf] + // fn edge_of_glob() { + // let path = Path::new("/work/node_modules"); + // let path_matcher = + // PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Unix).unwrap(); + // assert!( + // path_matcher.is_match(path), + // "Path matcher should match {path:?}" + // ); + // } + + // #[perf] + // fn file_in_dirs() { + // let path = Path::new("/work/.env"); + // let path_matcher = PathMatcher::new(&["**/.env".to_owned()], PathStyle::Unix).unwrap(); + // assert!( + // path_matcher.is_match(path), + // "Path matcher should match {path:?}" + // ); + // let path = Path::new("/work/package.json"); + // assert!( + // !path_matcher.is_match(path), + // "Path matcher should not match {path:?}" + // ); + // } + + // #[perf] + // fn project_search() { + // let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules"); + // let path_matcher = + // PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Unix).unwrap(); + // assert!( + // path_matcher.is_match(path), + // "Path matcher should match {path:?}" + // ); + // } + #[perf] + #[cfg(target_os = "windows")] + fn test_sanitized_path() { + let path = Path::new("C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::new(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + + let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::new(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + } + + #[perf] + #[cfg(target_os = "windows")] + fn test_sanitized_path_verbatim_unc() { + let path: Arc = PathBuf::from("\\\\?\\UNC\\server\\share\\file.txt").into(); + let sanitized_path = SanitizedPath::from_arc(path); + assert_eq!(sanitized_path.to_string(), "\\\\server\\share\\file.txt"); + } + + #[perf] + fn test_compare_numeric_segments() { + // Helper function to create peekable iterators and test + fn compare(a: &str, b: &str) -> Ordering { + let mut a_iter = a.chars().peekable(); + let mut b_iter = b.chars().peekable(); + + let result = compare_numeric_segments(&mut a_iter, &mut b_iter); + + // Verify iterators advanced correctly + assert!( + !a_iter.next().is_some_and(|c| c.is_ascii_digit()), + "Iterator a should have consumed all digits" + ); + assert!( + !b_iter.next().is_some_and(|c| c.is_ascii_digit()), + "Iterator b should have consumed all digits" + ); + + result + } + + // Basic numeric comparisons + assert_eq!(compare("0", "0"), Ordering::Equal); + assert_eq!(compare("1", "2"), Ordering::Less); + assert_eq!(compare("9", "10"), Ordering::Less); + assert_eq!(compare("10", "9"), Ordering::Greater); + assert_eq!(compare("99", "100"), Ordering::Less); + + // Leading zeros + assert_eq!(compare("0", "00"), Ordering::Less); + assert_eq!(compare("00", "0"), Ordering::Greater); + assert_eq!(compare("01", "1"), Ordering::Greater); + assert_eq!(compare("001", "1"), Ordering::Greater); + assert_eq!(compare("001", "01"), Ordering::Greater); + + // Same value different representation + assert_eq!(compare("000100", "100"), Ordering::Greater); + assert_eq!(compare("100", "0100"), Ordering::Less); + assert_eq!(compare("0100", "00100"), Ordering::Less); + + // Large numbers + assert_eq!(compare("9999999999", "10000000000"), Ordering::Less); + assert_eq!( + compare( + "340282366920938463463374607431768211455", // u128::MAX + "340282366920938463463374607431768211456" + ), + Ordering::Less + ); + assert_eq!( + compare( + "340282366920938463463374607431768211456", // > u128::MAX + "340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Iterator advancement verification + let mut a_iter = "123abc".chars().peekable(); + let mut b_iter = "456def".chars().peekable(); + + compare_numeric_segments(&mut a_iter, &mut b_iter); + + assert_eq!(a_iter.collect::(), "abc"); + assert_eq!(b_iter.collect::(), "def"); + } + + #[perf] + fn test_natural_sort() { + // Basic alphanumeric + assert_eq!(natural_sort("a", "b"), Ordering::Less); + assert_eq!(natural_sort("b", "a"), Ordering::Greater); + assert_eq!(natural_sort("a", "a"), Ordering::Equal); + + // Case sensitivity + assert_eq!(natural_sort("a", "A"), Ordering::Less); + assert_eq!(natural_sort("A", "a"), Ordering::Greater); + assert_eq!(natural_sort("aA", "aa"), Ordering::Greater); + assert_eq!(natural_sort("aa", "aA"), Ordering::Less); + + // Numbers + assert_eq!(natural_sort("1", "2"), Ordering::Less); + assert_eq!(natural_sort("2", "10"), Ordering::Less); + assert_eq!(natural_sort("02", "10"), Ordering::Less); + assert_eq!(natural_sort("02", "2"), Ordering::Greater); + + // Mixed alphanumeric + assert_eq!(natural_sort("a1", "a2"), Ordering::Less); + assert_eq!(natural_sort("a2", "a10"), Ordering::Less); + assert_eq!(natural_sort("a02", "a2"), Ordering::Greater); + assert_eq!(natural_sort("a1b", "a1c"), Ordering::Less); + + // Multiple numeric segments + assert_eq!(natural_sort("1a2", "1a10"), Ordering::Less); + assert_eq!(natural_sort("1a10", "1a2"), Ordering::Greater); + assert_eq!(natural_sort("2a1", "10a1"), Ordering::Less); + + // Special characters + assert_eq!(natural_sort("a-1", "a-2"), Ordering::Less); + assert_eq!(natural_sort("a_1", "a_2"), Ordering::Less); + assert_eq!(natural_sort("a.1", "a.2"), Ordering::Less); + + // Unicode + assert_eq!(natural_sort("文1", "文2"), Ordering::Less); + assert_eq!(natural_sort("文2", "文10"), Ordering::Less); + assert_eq!(natural_sort("🔤1", "🔤2"), Ordering::Less); + + // Empty and special cases + assert_eq!(natural_sort("", ""), Ordering::Equal); + assert_eq!(natural_sort("", "a"), Ordering::Less); + assert_eq!(natural_sort("a", ""), Ordering::Greater); + assert_eq!(natural_sort(" ", " "), Ordering::Less); + + // Mixed everything + assert_eq!(natural_sort("File-1.txt", "File-2.txt"), Ordering::Less); + assert_eq!(natural_sort("File-02.txt", "File-2.txt"), Ordering::Greater); + assert_eq!(natural_sort("File-2.txt", "File-10.txt"), Ordering::Less); + assert_eq!(natural_sort("File_A1", "File_A2"), Ordering::Less); + assert_eq!(natural_sort("File_a1", "File_A1"), Ordering::Less); + } + + #[perf] + fn test_compare_paths() { + // Helper function for cleaner tests + fn compare(a: &str, is_a_file: bool, b: &str, is_b_file: bool) -> Ordering { + compare_paths((Path::new(a), is_a_file), (Path::new(b), is_b_file)) + } + + // Basic path comparison + assert_eq!(compare("a", true, "b", true), Ordering::Less); + assert_eq!(compare("b", true, "a", true), Ordering::Greater); + assert_eq!(compare("a", true, "a", true), Ordering::Equal); + + // Files vs Directories + assert_eq!(compare("a", true, "a", false), Ordering::Greater); + assert_eq!(compare("a", false, "a", true), Ordering::Less); + assert_eq!(compare("b", false, "a", true), Ordering::Less); + + // Extensions + assert_eq!(compare("a.txt", true, "a.md", true), Ordering::Greater); + assert_eq!(compare("a.md", true, "a.txt", true), Ordering::Less); + assert_eq!(compare("a", true, "a.txt", true), Ordering::Less); + + // Nested paths + assert_eq!(compare("dir/a", true, "dir/b", true), Ordering::Less); + assert_eq!(compare("dir1/a", true, "dir2/a", true), Ordering::Less); + assert_eq!(compare("dir/sub/a", true, "dir/a", true), Ordering::Less); + + // Case sensitivity in paths + assert_eq!( + compare("Dir/file", true, "dir/file", true), + Ordering::Greater + ); + assert_eq!( + compare("dir/File", true, "dir/file", true), + Ordering::Greater + ); + assert_eq!(compare("dir/file", true, "Dir/File", true), Ordering::Less); + + // Hidden files and special names + assert_eq!(compare(".hidden", true, "visible", true), Ordering::Less); + assert_eq!(compare("_special", true, "normal", true), Ordering::Less); + assert_eq!(compare(".config", false, ".data", false), Ordering::Less); + + // Mixed numeric paths + assert_eq!( + compare("dir1/file", true, "dir2/file", true), + Ordering::Less + ); + assert_eq!( + compare("dir2/file", true, "dir10/file", true), + Ordering::Less + ); + assert_eq!( + compare("dir02/file", true, "dir2/file", true), + Ordering::Greater + ); + + // Root paths + assert_eq!(compare("/a", true, "/b", true), Ordering::Less); + assert_eq!(compare("/", false, "/a", true), Ordering::Less); + + // Complex real-world examples + assert_eq!( + compare("project/src/main.rs", true, "project/src/lib.rs", true), + Ordering::Greater + ); + assert_eq!( + compare( + "project/tests/test_1.rs", + true, + "project/tests/test_2.rs", + true + ), + Ordering::Less + ); + assert_eq!( + compare( + "project/v1.0.0/README.md", + true, + "project/v1.10.0/README.md", + true + ), + Ordering::Less + ); + } + + #[perf] + fn test_natural_sort_case_sensitivity() { + std::thread::sleep(std::time::Duration::from_millis(100)); + // Same letter different case - lowercase should come first + assert_eq!(natural_sort("a", "A"), Ordering::Less); + assert_eq!(natural_sort("A", "a"), Ordering::Greater); + assert_eq!(natural_sort("a", "a"), Ordering::Equal); + assert_eq!(natural_sort("A", "A"), Ordering::Equal); + + // Mixed case strings + assert_eq!(natural_sort("aaa", "AAA"), Ordering::Less); + assert_eq!(natural_sort("AAA", "aaa"), Ordering::Greater); + assert_eq!(natural_sort("aAa", "AaA"), Ordering::Less); + + // Different letters + assert_eq!(natural_sort("a", "b"), Ordering::Less); + assert_eq!(natural_sort("A", "b"), Ordering::Less); + assert_eq!(natural_sort("a", "B"), Ordering::Less); + } + + #[perf] + fn test_natural_sort_with_numbers() { + // Basic number ordering + assert_eq!(natural_sort("file1", "file2"), Ordering::Less); + assert_eq!(natural_sort("file2", "file10"), Ordering::Less); + assert_eq!(natural_sort("file10", "file2"), Ordering::Greater); + + // Numbers in different positions + assert_eq!(natural_sort("1file", "2file"), Ordering::Less); + assert_eq!(natural_sort("file1text", "file2text"), Ordering::Less); + assert_eq!(natural_sort("text1file", "text2file"), Ordering::Less); + + // Multiple numbers in string + assert_eq!(natural_sort("file1-2", "file1-10"), Ordering::Less); + assert_eq!(natural_sort("2-1file", "10-1file"), Ordering::Less); + + // Leading zeros + assert_eq!(natural_sort("file002", "file2"), Ordering::Greater); + assert_eq!(natural_sort("file002", "file10"), Ordering::Less); + + // Very large numbers + assert_eq!( + natural_sort("file999999999999999999999", "file999999999999999999998"), + Ordering::Greater + ); + + // u128 edge cases + + // Numbers near u128::MAX (340,282,366,920,938,463,463,374,607,431,768,211,455) + assert_eq!( + natural_sort( + "file340282366920938463463374607431768211454", + "file340282366920938463463374607431768211455" + ), + Ordering::Less + ); + + // Equal length numbers that overflow u128 + assert_eq!( + natural_sort( + "file340282366920938463463374607431768211456", + "file340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Different length numbers that overflow u128 + assert_eq!( + natural_sort( + "file3402823669209384634633746074317682114560", + "file340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Leading zeros with numbers near u128::MAX + assert_eq!( + natural_sort( + "file0340282366920938463463374607431768211455", + "file340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Very large numbers with different lengths (both overflow u128) + assert_eq!( + natural_sort( + "file999999999999999999999999999999999999999999999999", + "file9999999999999999999999999999999999999999999999999" + ), + Ordering::Less + ); + } + + #[perf] + fn test_natural_sort_case_sensitive() { + // Numerically smaller values come first. + assert_eq!(natural_sort("File1", "file2"), Ordering::Less); + assert_eq!(natural_sort("file1", "File2"), Ordering::Less); + + // Numerically equal values: the case-insensitive comparison decides first. + // Case-sensitive comparison only occurs when both are equal case-insensitively. + assert_eq!(natural_sort("Dir1", "dir01"), Ordering::Less); + assert_eq!(natural_sort("dir2", "Dir02"), Ordering::Less); + assert_eq!(natural_sort("dir2", "dir02"), Ordering::Less); + + // Numerically equal and case-insensitively equal: + // the lexicographically smaller (case-sensitive) one wins. + assert_eq!(natural_sort("dir1", "Dir1"), Ordering::Less); + assert_eq!(natural_sort("dir02", "Dir02"), Ordering::Less); + assert_eq!(natural_sort("dir10", "Dir10"), Ordering::Less); + } + + #[perf] + fn test_natural_sort_edge_cases() { + // Empty strings + assert_eq!(natural_sort("", ""), Ordering::Equal); + assert_eq!(natural_sort("", "a"), Ordering::Less); + assert_eq!(natural_sort("a", ""), Ordering::Greater); + + // Special characters + assert_eq!(natural_sort("file-1", "file_1"), Ordering::Less); + assert_eq!(natural_sort("file.1", "file_1"), Ordering::Less); + assert_eq!(natural_sort("file 1", "file_1"), Ordering::Less); + + // Unicode characters + // 9312 vs 9313 + assert_eq!(natural_sort("file①", "file②"), Ordering::Less); + // 9321 vs 9313 + assert_eq!(natural_sort("file⑩", "file②"), Ordering::Greater); + // 28450 vs 23383 + assert_eq!(natural_sort("file漢", "file字"), Ordering::Greater); + + // Mixed alphanumeric with special chars + assert_eq!(natural_sort("file-1a", "file-1b"), Ordering::Less); + assert_eq!(natural_sort("file-1.2", "file-1.10"), Ordering::Less); + assert_eq!(natural_sort("file-1.10", "file-1.2"), Ordering::Greater); + } + + #[test] + fn test_multiple_extensions() { + // No extensions + let path = Path::new("/a/b/c/file_name"); + assert_eq!(path.multiple_extensions(), None); + + // Only one extension + let path = Path::new("/a/b/c/file_name.tsx"); + assert_eq!(path.multiple_extensions(), None); + + // Stories sample extension + let path = Path::new("/a/b/c/file_name.stories.tsx"); + assert_eq!(path.multiple_extensions(), Some("stories.tsx".to_string())); + + // Longer sample extension + let path = Path::new("/a/b/c/long.app.tar.gz"); + assert_eq!(path.multiple_extensions(), Some("app.tar.gz".to_string())); + } + + #[test] + fn test_strip_path_suffix() { + let base = Path::new("/a/b/c/file_name"); + let suffix = Path::new("file_name"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b/c"))); + + let base = Path::new("/a/b/c/file_name.tsx"); + let suffix = Path::new("file_name.tsx"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b/c"))); + + let base = Path::new("/a/b/c/file_name.stories.tsx"); + let suffix = Path::new("c/file_name.stories.tsx"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b"))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("b/c/long.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a"))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("/a/b/c/long.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new(""))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("/a/b/c/no_match.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), None); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), None); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_wsl_path() { + use super::WslPath; + let path = "/a/b/c"; + assert_eq!(WslPath::from_path(&path), None); + + let path = r"\\wsl.localhost"; + assert_eq!(WslPath::from_path(&path), None); + + let path = r"\\wsl.localhost\Distro"; + assert_eq!( + WslPath::from_path(&path), + Some(WslPath { + distro: "Distro".to_owned(), + path: "/".into(), + }) + ); + + let path = r"\\wsl.localhost\Distro\blue"; + assert_eq!( + WslPath::from_path(&path), + Some(WslPath { + distro: "Distro".to_owned(), + path: "/blue".into() + }) + ); + + let path = r"\\wsl$\archlinux\tomato\.\paprika\..\aubergine.txt"; + assert_eq!( + WslPath::from_path(&path), + Some(WslPath { + distro: "archlinux".to_owned(), + path: "/tomato/paprika/../aubergine.txt".into() + }) + ); + + let path = r"\\windows.localhost\Distro\foo"; + assert_eq!(WslPath::from_path(&path), None); + } + + #[test] + fn test_url_to_file_path_ext_posix_basic() { + use super::UrlExt; + + let url = url::Url::parse("file:///home/user/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/home/user/file.txt")) + ); + + let url = url::Url::parse("file:///").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/")) + ); + + let url = url::Url::parse("file:///a/b/c/d/e").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/a/b/c/d/e")) + ); + } + + #[test] + fn test_url_to_file_path_ext_posix_percent_encoding() { + use super::UrlExt; + + let url = url::Url::parse("file:///home/user/file%20with%20spaces.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/home/user/file with spaces.txt")) + ); + + let url = url::Url::parse("file:///path%2Fwith%2Fencoded%2Fslashes").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/path/with/encoded/slashes")) + ); + + let url = url::Url::parse("file:///special%23chars%3F.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/special#chars?.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_posix_localhost() { + use super::UrlExt; + + let url = url::Url::parse("file://localhost/home/user/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/home/user/file.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_posix_rejects_host() { + use super::UrlExt; + + let url = url::Url::parse("file://somehost/home/user/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Unix), Err(())); + } + + #[test] + fn test_url_to_file_path_ext_posix_windows_drive_letter() { + use super::UrlExt; + + let url = url::Url::parse("file:///C:").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/C:/")) + ); + + let url = url::Url::parse("file:///D|").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Unix), + Ok(PathBuf::from("/D|/")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_basic() { + use super::UrlExt; + + let url = url::Url::parse("file:///C:/Users/user/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\user\\file.txt")) + ); + + let url = url::Url::parse("file:///D:/folder/subfolder/file.rs").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("D:\\folder\\subfolder\\file.rs")) + ); + + let url = url::Url::parse("file:///C:/").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_encoded_drive_letter() { + use super::UrlExt; + + let url = url::Url::parse("file:///C%3A/Users/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\file.txt")) + ); + + let url = url::Url::parse("file:///c%3a/Users/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("c:\\Users\\file.txt")) + ); + + let url = url::Url::parse("file:///D%3A/folder/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("D:\\folder\\file.txt")) + ); + + let url = url::Url::parse("file:///d%3A/folder/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("d:\\folder\\file.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_unc_path() { + use super::UrlExt; + + let url = url::Url::parse("file://server/share/path/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("\\\\server\\share\\path\\file.txt")) + ); + + let url = url::Url::parse("file://server/share").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("\\\\server\\share")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_percent_encoding() { + use super::UrlExt; + + let url = url::Url::parse("file:///C:/Users/user/file%20with%20spaces.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\user\\file with spaces.txt")) + ); + + let url = url::Url::parse("file:///C:/special%23chars%3F.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\special#chars?.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_invalid_drive() { + use super::UrlExt; + + let url = url::Url::parse("file:///1:/path/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("file:///CC:/path/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("file:///C/path/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("file:///invalid").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + } + + #[test] + fn test_url_to_file_path_ext_non_file_scheme() { + use super::UrlExt; + + let url = url::Url::parse("http://example.com/path").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Unix), Err(())); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("https://example.com/path").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Unix), Err(())); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + } + + #[test] + fn test_url_to_file_path_ext_windows_localhost() { + use super::UrlExt; + + let url = url::Url::parse("file://localhost/C:/Users/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\file.txt")) + ); + } +}