use super::{MatchAlgorithm, completion_options::NuMatcher};
use crate::completions::CompletionOptions;
use nu_ansi_term::Style;
use nu_engine::env_to_string;
use nu_path::dots::expand_ndots;
use nu_path::{expand_to_real_path, home_dir};
use nu_protocol::{
Span,
engine::{EngineState, Stack, StateWorkingSet},
};
use nu_utils::IgnoreCaseExt;
use nu_utils::get_ls_colors;
use std::fmt::Write;
use std::path::{Component, MAIN_SEPARATOR as SEP, Path, PathBuf, is_separator};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Default)]
pub struct PathBuiltFromString {
cwd: PathBuf,
parts: Vec<MatchedPart>,
isdir: bool,
}
#[derive(Clone, Default)]
pub struct MatchedPart {
text: String,
match_indices: Vec<usize>,
}
fn complete_rec(
partial: &[&str],
built_paths: &[PathBuiltFromString],
options: &CompletionOptions,
want_directory: bool,
isdir: bool,
enable_exact_match: bool,
) -> Vec<PathBuiltFromString> {
let has_more = !partial.is_empty() && (partial.len() > 1 || isdir);
if let Some((&base, rest)) = partial.split_first()
&& base.chars().all(|c| c == '.')
&& has_more
{
let built_paths: Vec<_> = built_paths
.iter()
.map(|built| {
let mut built = built.clone();
built.parts.push(MatchedPart {
text: base.to_string(),
match_indices: Vec::new(),
});
built.isdir = true;
built
})
.collect();
return complete_rec(
rest,
&built_paths,
options,
want_directory,
isdir,
enable_exact_match,
);
}
let prefix = partial.first().unwrap_or(&"");
let mut matcher = NuMatcher::new(prefix, options, true);
let mut exact_match = None;
let mut multiple_exact_matches = false;
for built in built_paths {
let mut path = built.cwd.clone();
for part in &built.parts {
path.push(part.text.as_str());
}
let Ok(result) = path.read_dir() else {
continue;
};
for entry in result.filter_map(|e| e.ok()) {
let entry_name = entry.file_name().to_string_lossy().into_owned();
let entry_isdir = entry.path().is_dir();
let mut built = built.clone();
built.isdir = entry_isdir;
if !want_directory || entry_isdir {
if enable_exact_match && !multiple_exact_matches && has_more {
let matches = if options.case_sensitive {
entry_name.eq(prefix)
} else {
entry_name.eq_ignore_case(prefix)
};
if matches {
if exact_match.is_none() {
let mut built_exact = built.clone();
let match_indices = (0..entry_name.graphemes(true).count()).collect();
built_exact.parts.push(MatchedPart {
text: entry_name.clone(),
match_indices,
});
exact_match = Some(built_exact);
} else {
multiple_exact_matches = true;
}
}
}
matcher.add(entry_name.clone(), (built, entry_name));
}
}
}
if !multiple_exact_matches && let Some(built) = exact_match {
return complete_rec(
&partial[1..],
&[built],
options,
want_directory,
isdir,
true,
);
}
let completion_iter =
matcher
.results()
.into_iter()
.map(|((mut built, last_entry_name), last_match_indices)| {
built.parts.push(MatchedPart {
text: last_entry_name,
match_indices: last_match_indices,
});
built
});
if has_more {
completion_iter
.flat_map(|completion| {
complete_rec(
&partial[1..],
&[completion],
options,
want_directory,
isdir,
false,
)
})
.collect()
} else {
completion_iter.collect()
}
}
#[derive(Debug)]
enum OriginalCwd {
None,
Home,
Prefix(String),
}
pub fn surround_remove(partial: &str) -> String {
for c in ['`', '"', '\''] {
if partial.starts_with(c) {
let ret = partial.strip_prefix(c).unwrap_or(partial);
return match ret.split(c).collect::<Vec<_>>()[..] {
[inside] => inside.to_string(),
[inside, outside] if inside.ends_with(is_separator) => format!("{inside}{outside}"),
_ => ret.to_string(),
};
}
}
partial.to_string()
}
pub struct FileSuggestion {
pub span: nu_protocol::Span,
pub path: String,
pub style: Option<Style>,
pub is_dir: bool,
pub display_override: Option<String>,
pub match_indices: Vec<usize>,
}
pub fn complete_item(
want_directory: bool,
span: nu_protocol::Span,
partial: &str,
cwds: &[impl AsRef<str>],
options: &CompletionOptions,
engine_state: &EngineState,
stack: &Stack,
) -> Vec<FileSuggestion> {
let cleaned_partial = surround_remove(partial);
let isdir = cleaned_partial.ends_with(is_separator);
let expanded_partial = expand_ndots(Path::new(&cleaned_partial));
let should_collapse_dots = expanded_partial != Path::new(&cleaned_partial);
let mut partial = expanded_partial.to_string_lossy().to_string();
#[cfg(unix)]
let path_separator = SEP;
#[cfg(windows)]
let path_separator = cleaned_partial
.chars()
.rfind(|c: &char| is_separator(*c))
.unwrap_or(SEP);
if cleaned_partial.ends_with(&format!("{path_separator}.")) {
write!(partial, "{path_separator}.").expect("writing to a String is infallible");
}
let cwd_pathbufs: Vec<_> = cwds
.iter()
.map(|cwd| Path::new(cwd.as_ref()).to_path_buf())
.collect();
let ls_colors = (engine_state.config.completions.use_ls_colors
&& engine_state.config.use_ansi_coloring.get(engine_state))
.then(|| {
let ls_colors_env_str = stack
.get_env_var(engine_state, "LS_COLORS")
.and_then(|v| env_to_string("LS_COLORS", v, engine_state, stack).ok());
get_ls_colors(ls_colors_env_str)
});
let mut cwds = cwd_pathbufs.clone();
let mut prefix_len = 0;
let mut original_cwd = OriginalCwd::None;
let mut components = Path::new(&partial).components().peekable();
match components.peek().cloned() {
Some(c @ Component::Prefix(..)) => {
cwds = vec![[c, Component::RootDir].iter().collect()];
prefix_len = c.as_os_str().len();
original_cwd = OriginalCwd::Prefix(c.as_os_str().to_string_lossy().into_owned());
}
Some(c @ Component::RootDir) => {
cwds = vec![PathBuf::from(c.as_os_str())];
prefix_len = 1;
original_cwd = OriginalCwd::Prefix(String::new());
}
Some(Component::Normal(home)) if home.to_string_lossy() == "~" => {
cwds = home_dir()
.map(|dir| vec![dir.into()])
.unwrap_or(cwd_pathbufs);
prefix_len = 1;
original_cwd = OriginalCwd::Home;
}
_ => {}
};
let after_prefix = &partial[prefix_len..];
let partial: Vec<_> = after_prefix
.strip_prefix(is_separator)
.unwrap_or(after_prefix)
.split(is_separator)
.filter(|s| !s.is_empty())
.collect();
complete_rec(
partial.as_slice(),
&cwds
.into_iter()
.map(|cwd| PathBuiltFromString {
cwd,
parts: Vec::new(),
isdir: false,
})
.collect::<Vec<_>>(),
options,
want_directory,
isdir,
options.match_algorithm == MatchAlgorithm::Prefix,
)
.into_iter()
.map(|mut p| {
if should_collapse_dots {
p = collapse_ndots(p);
}
let is_dir = p.isdir;
let mut path = match &original_cwd {
OriginalCwd::None => String::new(),
OriginalCwd::Home => format!("~{path_separator}"),
OriginalCwd::Prefix(s) => format!("{s}{path_separator}"),
};
let mut match_index_offset = path.graphemes(true).count();
let mut match_indices = Vec::new();
for (i, part) in p.parts.iter().enumerate() {
path.push_str(&part.text);
for ind in &part.match_indices {
match_indices.push(ind + match_index_offset);
}
match_index_offset += part.text.graphemes(true).count();
if i != p.parts.len() - 1 {
path.push(path_separator);
match_index_offset += path_separator.len_utf8();
}
}
if p.isdir {
path.push(path_separator);
}
let real_path = expand_to_real_path(&path);
let metadata = std::fs::symlink_metadata(&real_path).ok();
let style = ls_colors.as_ref().map(|lsc| {
lsc.style_for_path_with_metadata(&real_path, metadata.as_ref())
.map(lscolors::Style::to_nu_ansi_term_style)
.unwrap_or_default()
});
let (value, display_override) = if let Some(escaped) = escape_path(&path) {
(escaped, Some(path))
} else {
(path, None)
};
FileSuggestion {
span,
path: value,
style,
is_dir,
display_override,
match_indices,
}
})
.collect()
}
pub fn escape_path(path: &str) -> Option<String> {
if nu_glob::is_glob(path) || path.contains('`') {
let pathbuf = nu_path::expand_tilde(path);
let path = pathbuf.to_string_lossy();
if path.contains('\'') {
Some(format!("{path:?}"))
} else {
Some(format!("'{path}'"))
}
} else {
let contaminated =
path.contains(['\'', '"', ' ', '#', '(', ')', '{', '}', '[', ']', '|', ';']);
let maybe_flag = path.starts_with('-');
let maybe_variable = path.starts_with('$');
let maybe_number = path.parse::<f64>().is_ok();
if contaminated || maybe_flag || maybe_variable || maybe_number {
Some(format!("`{path}`"))
} else {
None
}
}
}
pub struct AdjustView {
pub prefix: String,
pub span: Span,
pub readjusted: bool,
}
pub fn adjust_if_intermediate(
prefix: &str,
working_set: &StateWorkingSet,
mut span: nu_protocol::Span,
) -> AdjustView {
let span_contents = String::from_utf8_lossy(working_set.get_span_contents(span)).to_string();
let mut prefix = prefix.to_string();
let readjusted = span_contents.chars().count() - prefix.chars().count() > 1;
if readjusted {
let remnant: String = span_contents
.chars()
.skip(prefix.chars().count() + 1)
.take_while(|&c| !is_separator(c))
.collect();
prefix.push_str(&remnant);
span = Span::new(span.start, span.start + prefix.chars().count() + 1);
}
AdjustView {
prefix,
span,
readjusted,
}
}
fn collapse_ndots(path: PathBuiltFromString) -> PathBuiltFromString {
let mut result = PathBuiltFromString {
parts: Vec::with_capacity(path.parts.len()),
isdir: path.isdir,
cwd: path.cwd,
};
let mut dot_count = 0;
for part in path.parts {
if &part.text == ".." {
dot_count += 1;
} else {
if dot_count > 0 {
result.parts.push(MatchedPart {
text: ".".repeat(dot_count + 1),
match_indices: Vec::new(),
});
dot_count = 0;
}
result.parts.push(part);
}
}
if dot_count > 0 {
result.parts.push(MatchedPart {
text: ".".repeat(dot_count + 1),
match_indices: Vec::new(),
});
}
result
}