use std::fmt::{Display, Formatter}; use std::fs::DirEntry; use itertools::{iproduct, Itertools}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use typed_path::{TypedPath, TypedPathBuf}; use warp_command_signatures::{IconType, PathSuggestionType}; use galaxy_util::path::HOME_DIR_ENV_VAR_PREFIX; use crate::completer::suggest::Priority; use crate::completer::{ context::PathCompletionContext, matchers::MatchStrategy, suggest::{MatchedSuggestion, Suggestion, SuggestionType}, }; use crate::parsers::ParsedToken; /// TODO(CORE-3074): This only applies to Unix. const ROOT_DIR_STR: &str = "/"; lazy_static! { pub static ref CURR_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry { file_name: ".".to_owned(), file_type: EngineFileType::Directory, }; pub static ref PARENT_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry { file_name: "..".to_owned(), file_type: EngineFileType::Directory, }; } /// A `DirEntry` for the completions engine that abstracts whether the contents /// come from a remote or local filesystem. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct EngineDirEntry { pub file_name: String, pub file_type: EngineFileType, } impl EngineDirEntry { pub fn is_dir(&self) -> bool { self.file_type == EngineFileType::Directory } pub fn file_name(&self) -> &str { self.file_name.as_str() } pub fn is_hidden(&self) -> bool { self.file_name.starts_with('.') } } impl TryFrom for EngineDirEntry { type Error = std::io::Error; fn try_from(value: DirEntry) -> Result { let file_type = value.file_type()?; let is_dir = if file_type.is_dir() { true } else if file_type.is_symlink() { // If the file is a symlink, follow the symlink and check if the target is a directory. value .path() .metadata() .map(|metadata| metadata.is_dir()) .unwrap_or(false) } else { false }; let file_type = if is_dir { EngineFileType::Directory } else { EngineFileType::File }; Ok(Self { file_name: value.file_name().to_string_lossy().to_string(), file_type, }) } } #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum EngineFileType { Directory, File, } impl Display for EngineFileType { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { EngineFileType::Directory => "Directory", EngineFileType::File => "File", } ) } } impl From for PathSuggestionType { fn from(path_type: EngineFileType) -> Self { match path_type { EngineFileType::Directory => Self::Folder, EngineFileType::File => Self::File, } } } /// Returns the sorted directories relative to the provided path and filter. /// /// Note we are returning a Vector instead of iterator here because Rust currently doesn't support /// returning opaque types (impl) in traits. This should have minimum impact on the memory allocation /// since we are already calling `sort_by` before collecting which allocates memory. pub(crate) async fn sorted_directories_relative_to( path: &ParsedToken, matcher: MatchStrategy, ctx: &dyn PathCompletionContext, ) -> Vec { list_directory_contents(path, matcher, ctx) .await .into_iter() .filter(|path_suggestion| { path_suggestion .suggestion .file_type .is_some_and(|file_type| file_type == EngineFileType::Directory) }) .sorted_by(|suggestion_a, suggestion_b| { suggestion_a .suggestion .cmp_by_display(&suggestion_b.suggestion) }) .collect() } pub async fn sorted_paths_relative_to( path: &ParsedToken, matcher: MatchStrategy, ctx: &dyn PathCompletionContext, ) -> Vec { list_directory_contents(path, matcher, ctx) .await .into_iter() .sorted_by(|suggestion_a, suggestion_b| { suggestion_a .suggestion .cmp_by_display(&suggestion_b.suggestion) }) .collect() } /// Lists all directory contents within the directory identified by the parent directory of /// `relative_to`. /// If `relative_to` is `foo/bar/`, directory contents beanth `bar/` will be returned. /// If `relative_to` is `foo/bar/a`, directory contents relative to `/bar` are returned, while /// ensuring they match the trailing `a`. /// `relative_to` can contain backslash escaped tildes so we can distinguish between tildes that /// should be expanded into the home directory and a literal tilde. /// NOTE: The resulting suggestion replacements are shell-escaped; display values are unescaped. async fn list_directory_contents( relative_to: &ParsedToken, matcher: MatchStrategy, ctx: &dyn PathCompletionContext, ) -> Vec { let home_dir = ctx.home_directory(); let path_separators = ctx.path_separators(); let split_path = SplitPath::new( ctx.pwd(), relative_to.as_str(), home_dir, path_separators.all, ); let dir_entries = ctx .list_directory_entries(split_path.directory_absolute_path.clone()) .await; let root_dir_entry = (split_path.directory_absolute_path.to_str() == Some(ROOT_DIR_STR)).then(|| { EngineDirEntry { file_name: ROOT_DIR_STR.to_owned(), file_type: EngineFileType::Directory, } }); dir_entries .iter() .chain(root_dir_entry.iter()) .chain([&*CURR_DIRECTORY_ENTRY, &*PARENT_DIRECTORY_ENTRY]) .filter_map(move |entry| { let mut file_name = entry.file_name().to_string(); let match_type = matcher.get_match_type(&split_path.file_name, file_name.as_str())?; let path = if entry.file_name() == ROOT_DIR_STR { ROOT_DIR_STR.to_owned() } else { if entry.is_dir() { file_name.push(path_separators.main); } // We use `shell_escape()` instead of `escape()` on the relative path name to allow // home directory expansion if needed. format!( "{}{}", if split_path.directory_relative_path_name.is_empty() { "".to_owned() } else { // `directory_relative_path_name` may have escaped tildes which we use to // distinguish between a tilde representing the home directory and a literal // tilde. `shell_escape()` will doubly escape an escaped tilde which is // incorrect so we correct that behavior here. ctx.shell_family() .shell_escape(split_path.directory_relative_path_name.as_str()) .replace(r"\\\~", r"\~") }, // Home directory expansion is never needed on file names, so we use the // standard `escape()`. ctx.shell_family().escape(file_name.as_str()) ) }; (!entry.is_hidden() || split_path.file_name.starts_with('.')).then(|| { let mut suggestion = Suggestion::new( file_name.as_str(), path, Some(entry.file_type.to_string()), SuggestionType::Argument, Priority::default(), ); suggestion.file_type = Some(entry.file_type); suggestion.override_icon = Some(match entry.file_type { EngineFileType::File => IconType::File, EngineFileType::Directory => IconType::Folder, }); MatchedSuggestion { suggestion, match_type, } }) }) .collect_vec() } /// A path split into the parent path (the entire piece before the last separator) and the /// file_name (the piece after the last separator). #[derive(Debug, PartialEq, Eq)] struct SplitPath { /// The absolute path to the directory containing the file named `file_name`. directory_absolute_path: TypedPathBuf, /// The path to the directory containing the file named `file_name`, relative to the current /// working directory. This is may contain unexpanded `~` or `$HOME`. directory_relative_path_name: String, /// The name of the `file`. file_name: String, } impl SplitPath { /// Returns a `SplitPath` based on the given path values. /// /// `current_directory` is the directory to which `relative_path` is relative. /// `relative_path` may contain '~' or '$HOME'. If `relative_path` begins with one of those /// strings, we expand that part of the path to the given `home_directory` value, if it is /// `Some()`. Note that `relative_path` comes directly from a user-specified path token. This /// may contain escaped tildes (for example if the user is completing on a path that contains /// literal tildes), which need to be unescaped before using the path to generate path /// suggestions. fn new( current_directory: TypedPath, relative_path: &str, home_directory: Option<&str>, path_separators: &[char], ) -> Self { let (directory_relative_path_name, file_name) = match relative_path.rfind(path_separators) { Some(pos) => relative_path.split_at(pos + 1), None => ("", relative_path), }; let directory_absolute_path = if directory_relative_path_name.is_empty() { current_directory.to_path_buf() } else if let Some(rest) = iproduct!([HOME_DIR_ENV_VAR_PREFIX, "~"], path_separators) .find_map(|(prefix, sep)| { directory_relative_path_name.strip_prefix(&format!("{prefix}{sep}")) }) { let mut home_directory = TypedPathBuf::from(home_directory.unwrap_or_default()); home_directory.push(rest.replace(r"\~", "~")); home_directory } else { current_directory.join(directory_relative_path_name.replace(r"\~", "~")) }; // Unescape escaped tildes in the filename. let file_name = file_name.replace(r"\~", "~"); SplitPath { directory_absolute_path, directory_relative_path_name: directory_relative_path_name.to_owned(), file_name, } } } #[cfg(test)] #[path = "path_test.rs"] mod tests;