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

312 lines
11 KiB
Rust

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<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()
}
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_test.rs"]
mod tests;