Files
galaxy/crates/galaxy_completer/src/completer/engine/path.rs
T

433 lines
15 KiB
Rust

use std::collections::HashSet;
use std::fmt::{Display, Formatter};
use std::fs::DirEntry;
use std::sync::Arc;
use async_trait::async_trait;
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::{ShellFamily, HOME_DIR_ENV_VAR_PREFIX};
use crate::completer::context::{PathCompletionContext, PathSeparators};
use crate::completer::matchers::MatchStrategy;
use crate::completer::suggest::{MatchedSuggestion, Priority, 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<DirEntry> for EngineDirEntry {
type Error = std::io::Error;
fn try_from(value: DirEntry) -> Result<Self, Self::Error> {
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<EngineFileType> 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<MatchedSuggestion> {
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()
}
/// Like `sorted_directories_relative_to`, but iterates `$CDPATH` in shell
/// order (empty/`.` entry = pwd at that position; pwd appended as fallback if
/// no such entry) so completions surface in the order `cd` would resolve them.
pub(crate) async fn sorted_cd_directories(
path: &ParsedToken,
matcher: MatchStrategy,
ctx: &dyn PathCompletionContext,
) -> Vec<MatchedSuggestion> {
if !is_cdpath_eligible_token(path.as_str()) {
return sorted_directories_relative_to(path, matcher, ctx).await;
}
let Some(cdpath) = ctx.cdpath() else {
return sorted_directories_relative_to(path, matcher, ctx).await;
};
let mut results: Vec<MatchedSuggestion> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut pwd_searched = false;
let push_unique = |suggestions: Vec<MatchedSuggestion>,
results: &mut Vec<MatchedSuggestion>,
seen: &mut HashSet<String>| {
for s in suggestions {
if seen.insert(s.suggestion.display.to_string()) {
results.push(s);
}
}
};
for entry in cdpath.split(':') {
if entry.is_empty() || entry == "." {
if pwd_searched {
continue;
}
let pwd = sorted_directories_relative_to(path, matcher, ctx).await;
push_unique(pwd, &mut results, &mut seen);
pwd_searched = true;
} else {
let override_ctx = CdpathOverrideContext {
inner: ctx,
cdpath_pwd: resolve_cdpath_entry(entry, ctx),
};
let extra = sorted_directories_relative_to(path, matcher, &override_ctx).await;
push_unique(extra, &mut results, &mut seen);
}
}
// Shell falls back to pwd when no `$CDPATH` entry matches. If no `.`/empty
// entry positioned pwd already, append pwd matches now.
if !pwd_searched {
let pwd = sorted_directories_relative_to(path, matcher, ctx).await;
push_unique(pwd, &mut results, &mut seen);
}
results
}
/// Tilde-expand a `$CDPATH` entry against the shell's home dir, then resolve
/// relative entries against the shell's pwd so `cd` matches shell behavior.
fn resolve_cdpath_entry(entry: &str, ctx: &dyn PathCompletionContext) -> TypedPathBuf {
let expanded = if entry == "~" {
ctx.home_directory().unwrap_or_default().to_owned()
} else if let Some(rest) = entry.strip_prefix("~/") {
format!("{}/{}", ctx.home_directory().unwrap_or_default(), rest)
} else {
entry.to_owned()
};
let resolved = TypedPathBuf::from(expanded.as_str());
if resolved.is_absolute() {
resolved
} else {
ctx.pwd().join(expanded)
}
}
fn is_cdpath_eligible_token(token: &str) -> bool {
!(token.starts_with('/')
|| token.starts_with('~')
|| token.starts_with("./")
|| token.starts_with("../")
|| token == "."
|| token == "..")
}
/// Wraps a `PathCompletionContext` and overrides only `pwd()` so we can reuse
/// the existing engine to list directories under a `$CDPATH` entry.
struct CdpathOverrideContext<'a> {
inner: &'a dyn PathCompletionContext,
cdpath_pwd: TypedPathBuf,
}
#[async_trait]
impl<'a> PathCompletionContext for CdpathOverrideContext<'a> {
async fn list_directory_entries(&self, directory: TypedPathBuf) -> Arc<Vec<EngineDirEntry>> {
self.inner.list_directory_entries(directory).await
}
fn home_directory(&self) -> Option<&str> {
self.inner.home_directory()
}
fn cdpath(&self) -> Option<&str> {
// Avoid recursing — the outer call already iterates entries.
None
}
fn shell_family(&self) -> ShellFamily {
self.inner.shell_family()
}
fn pwd(&self) -> TypedPath<'_> {
self.cdpath_pwd.to_path()
}
fn path_separators(&self) -> PathSeparators {
self.inner.path_separators()
}
}
pub async fn sorted_paths_relative_to(
path: &ParsedToken,
matcher: MatchStrategy,
ctx: &dyn PathCompletionContext,
) -> Vec<MatchedSuggestion> {
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<MatchedSuggestion> {
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_tests.rs"]
mod tests;