312 lines
11 KiB
Rust
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;
|