Expand multiple dots in path in completions (#13725)

# Description
This is my first PR, and I'm looking for feedback to help me improve! 

This PR fixes #13380 by expanding the path prior to parsing it.
Also I've removed some unused code in
[completion_common.rs](84e92bb02c/crates/nu-cli/src/completions/completion_common.rs
)
# User-Facing Changes

Auto-completion for "cd .../" now works by expanding to "cd ../../". 

# Tests + Formatting

Formatted and added 2 tests for triple dots in the middle of a path and
at the end.
Also added a test for the expand_ndots() function.
This commit is contained in:
Anton Sagel 2024-09-09 20:39:18 +02:00 committed by GitHub
parent aff974552a
commit 6600b3edfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 303 additions and 14 deletions

View File

@ -1,3 +1,4 @@
use super::MatchAlgorithm;
use crate::{ use crate::{
completions::{matches, CompletionOptions}, completions::{matches, CompletionOptions},
SemanticSuggestion, SemanticSuggestion,
@ -5,6 +6,7 @@ use crate::{
use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
use nu_ansi_term::Style; use nu_ansi_term::Style;
use nu_engine::env_to_string; use nu_engine::env_to_string;
use nu_path::dots::expand_ndots;
use nu_path::{expand_to_real_path, home_dir}; use nu_path::{expand_to_real_path, home_dir};
use nu_protocol::{ use nu_protocol::{
engine::{EngineState, Stack, StateWorkingSet}, engine::{EngineState, Stack, StateWorkingSet},
@ -13,8 +15,6 @@ use nu_protocol::{
use nu_utils::get_ls_colors; use nu_utils::get_ls_colors;
use std::path::{is_separator, Component, Path, PathBuf, MAIN_SEPARATOR as SEP}; use std::path::{is_separator, Component, Path, PathBuf, MAIN_SEPARATOR as SEP};
use super::MatchAlgorithm;
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct PathBuiltFromString { pub struct PathBuiltFromString {
parts: Vec<String>, parts: Vec<String>,
@ -41,7 +41,7 @@ pub fn complete_rec(
let mut completions = vec![]; let mut completions = vec![];
if let Some((&base, rest)) = partial.split_first() { if let Some((&base, rest)) = partial.split_first() {
if (base == "." || base == "..") && (isdir || !rest.is_empty()) { if base.chars().all(|c| c == '.') && (isdir || !rest.is_empty()) {
let mut built = built.clone(); let mut built = built.clone();
built.parts.push(base.to_string()); built.parts.push(base.to_string());
built.isdir = true; built.isdir = true;
@ -156,16 +156,25 @@ pub fn complete_item(
engine_state: &EngineState, engine_state: &EngineState,
stack: &Stack, stack: &Stack,
) -> Vec<(nu_protocol::Span, String, Option<Style>)> { ) -> Vec<(nu_protocol::Span, String, Option<Style>)> {
let partial = surround_remove(partial); let cleaned_partial = surround_remove(partial);
let isdir = partial.ends_with(is_separator); 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)] #[cfg(unix)]
let path_separator = SEP; let path_separator = SEP;
#[cfg(windows)] #[cfg(windows)]
let path_separator = partial let path_separator = cleaned_partial
.chars() .chars()
.rfind(|c: &char| is_separator(*c)) .rfind(|c: &char| is_separator(*c))
.unwrap_or(SEP); .unwrap_or(SEP);
// Handle the trailing dot case
if cleaned_partial.ends_with(&format!("{path_separator}.")) {
partial.push_str(&format!("{path_separator}."));
}
let cwd_pathbuf = Path::new(cwd).to_path_buf(); let cwd_pathbuf = Path::new(cwd).to_path_buf();
let ls_colors = (engine_state.config.completions.use_ls_colors let ls_colors = (engine_state.config.completions.use_ls_colors
&& engine_state.config.use_ansi_coloring) && engine_state.config.use_ansi_coloring)
@ -185,16 +194,11 @@ pub fn complete_item(
match components.peek().cloned() { match components.peek().cloned() {
Some(c @ Component::Prefix(..)) => { Some(c @ Component::Prefix(..)) => {
// windows only by definition // windows only by definition
components.next();
if let Some(Component::RootDir) = components.peek().cloned() {
components.next();
};
cwd = [c, Component::RootDir].iter().collect(); cwd = [c, Component::RootDir].iter().collect();
prefix_len = c.as_os_str().len(); prefix_len = c.as_os_str().len();
original_cwd = OriginalCwd::Prefix(c.as_os_str().to_string_lossy().into_owned()); original_cwd = OriginalCwd::Prefix(c.as_os_str().to_string_lossy().into_owned());
} }
Some(c @ Component::RootDir) => { Some(c @ Component::RootDir) => {
components.next();
// This is kind of a hack. When joining an empty string with the rest, // This is kind of a hack. When joining an empty string with the rest,
// we add the slash automagically // we add the slash automagically
cwd = PathBuf::from(c.as_os_str()); cwd = PathBuf::from(c.as_os_str());
@ -202,7 +206,6 @@ pub fn complete_item(
original_cwd = OriginalCwd::Prefix(String::new()); original_cwd = OriginalCwd::Prefix(String::new());
} }
Some(Component::Normal(home)) if home.to_string_lossy() == "~" => { Some(Component::Normal(home)) if home.to_string_lossy() == "~" => {
components.next();
cwd = home_dir().map(Into::into).unwrap_or(cwd_pathbuf); cwd = home_dir().map(Into::into).unwrap_or(cwd_pathbuf);
prefix_len = 1; prefix_len = 1;
original_cwd = OriginalCwd::Home; original_cwd = OriginalCwd::Home;
@ -227,7 +230,10 @@ pub fn complete_item(
isdir, isdir,
) )
.into_iter() .into_iter()
.map(|p| { .map(|mut p| {
if should_collapse_dots {
p = collapse_ndots(p);
}
let path = original_cwd.apply(p, path_separator); let path = original_cwd.apply(p, path_separator);
let style = ls_colors.as_ref().map(|lsc| { let style = ls_colors.as_ref().map(|lsc| {
lsc.style_for_path_with_metadata( lsc.style_for_path_with_metadata(
@ -340,3 +346,37 @@ pub fn sort_completions<T>(
items items
} }
/// Collapse multiple ".." components into n-dots.
///
/// It performs the reverse operation of `expand_ndots`, collapsing sequences of ".." into n-dots,
/// such as "..." and "....".
///
/// The resulting path will use platform-specific path separators, regardless of what path separators were used in the input.
fn collapse_ndots(path: PathBuiltFromString) -> PathBuiltFromString {
let mut result = PathBuiltFromString {
parts: Vec::with_capacity(path.parts.len()),
isdir: path.isdir,
};
let mut dot_count = 0;
for part in path.parts {
if part == ".." {
dot_count += 1;
} else {
if dot_count > 0 {
result.parts.push(".".repeat(dot_count + 1));
dot_count = 0;
}
result.parts.push(part);
}
}
// Add any remaining dots
if dot_count > 0 {
result.parts.push(".".repeat(dot_count + 1));
}
result
}

View File

@ -339,7 +339,7 @@ fn file_completions() {
match_suggestions(&expected_paths, &suggestions); match_suggestions(&expected_paths, &suggestions);
// Test completions for hidden files // Test completions for hidden files
let target_dir = format!("ls {}{MAIN_SEPARATOR}.", folder(dir.join(".hidden_folder"))); let target_dir = format!("ls {}", file(dir.join(".hidden_folder").join(".")));
let suggestions = completer.complete(&target_dir, target_dir.len()); let suggestions = completer.complete(&target_dir, target_dir.len());
let expected_paths: Vec<String> = let expected_paths: Vec<String> =
@ -564,6 +564,58 @@ fn partial_completions() {
match_suggestions(&expected_paths, &suggestions); match_suggestions(&expected_paths, &suggestions);
} }
#[test]
fn partial_completion_with_dot_expansions() {
let (dir, _, engine, stack) = new_partial_engine();
let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack));
let dir_str = file(
dir.join("par")
.join("...")
.join("par")
.join("fi")
.join("so"),
);
let target_dir = format!("rm {dir_str}");
let suggestions = completer.complete(&target_dir, target_dir.len());
// Create the expected values
let expected_paths: Vec<String> = vec![
file(
dir.join("partial")
.join("...")
.join("partial_completions")
.join("final_partial")
.join("somefile"),
),
file(
dir.join("partial-a")
.join("...")
.join("partial_completions")
.join("final_partial")
.join("somefile"),
),
file(
dir.join("partial-b")
.join("...")
.join("partial_completions")
.join("final_partial")
.join("somefile"),
),
file(
dir.join("partial-c")
.join("...")
.join("partial_completions")
.join("final_partial")
.join("somefile"),
),
];
// Match the results
match_suggestions(&expected_paths, &suggestions);
}
#[test] #[test]
fn command_ls_with_filecompletion() { fn command_ls_with_filecompletion() {
let (_, _, engine, stack) = new_engine(); let (_, _, engine, stack) = new_engine();
@ -953,6 +1005,192 @@ fn folder_with_directorycompletions() {
match_suggestions(&expected_paths, &suggestions); match_suggestions(&expected_paths, &suggestions);
} }
#[test]
fn folder_with_directorycompletions_with_dots() {
// Create a new engine
let (dir, _, engine, stack) = new_engine();
let dir_str = dir
.join("directory_completion")
.join("folder_inside_folder")
.into_os_string()
.into_string()
.unwrap();
// Instantiate a new completer
let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack));
// Test completions for the current folder
let target_dir = format!("cd {dir_str}{MAIN_SEPARATOR}..{MAIN_SEPARATOR}");
let suggestions = completer.complete(&target_dir, target_dir.len());
// Create the expected values
let expected_paths: Vec<String> = vec![folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("..")
.join("folder_inside_folder"),
)];
#[cfg(windows)]
{
let target_dir = format!("cd {dir_str}/../");
let slash_suggestions = completer.complete(&target_dir, target_dir.len());
let expected_slash_paths: Vec<String> = expected_paths
.iter()
.map(|s| s.replace('\\', "/"))
.collect();
match_suggestions(&expected_slash_paths, &slash_suggestions);
}
// Match the results
match_suggestions(&expected_paths, &suggestions);
}
#[test]
fn folder_with_directorycompletions_with_three_trailing_dots() {
// Create a new engine
let (dir, _, engine, stack) = new_engine();
let dir_str = dir
.join("directory_completion")
.join("folder_inside_folder")
.into_os_string()
.into_string()
.unwrap();
// Instantiate a new completer
let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack));
// Test completions for the current folder
let target_dir = format!("cd {dir_str}{MAIN_SEPARATOR}...{MAIN_SEPARATOR}");
let suggestions = completer.complete(&target_dir, target_dir.len());
// Create the expected values
let expected_paths: Vec<String> = vec![
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("...")
.join("another"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("...")
.join("directory_completion"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("...")
.join("test_a"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("...")
.join("test_b"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("...")
.join(".hidden_folder"),
),
];
#[cfg(windows)]
{
let target_dir = format!("cd {dir_str}/.../");
let slash_suggestions = completer.complete(&target_dir, target_dir.len());
let expected_slash_paths: Vec<String> = expected_paths
.iter()
.map(|s| s.replace('\\', "/"))
.collect();
match_suggestions(&expected_slash_paths, &slash_suggestions);
}
// Match the results
match_suggestions(&expected_paths, &suggestions);
}
#[test]
fn folder_with_directorycompletions_do_not_collapse_dots() {
// Create a new engine
let (dir, _, engine, stack) = new_engine();
let dir_str = dir
.join("directory_completion")
.join("folder_inside_folder")
.into_os_string()
.into_string()
.unwrap();
// Instantiate a new completer
let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack));
// Test completions for the current folder
let target_dir = format!("cd {dir_str}{MAIN_SEPARATOR}..{MAIN_SEPARATOR}..{MAIN_SEPARATOR}");
let suggestions = completer.complete(&target_dir, target_dir.len());
// Create the expected values
let expected_paths: Vec<String> = vec![
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("..")
.join("..")
.join("another"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("..")
.join("..")
.join("directory_completion"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("..")
.join("..")
.join("test_a"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("..")
.join("..")
.join("test_b"),
),
folder(
dir.join("directory_completion")
.join("folder_inside_folder")
.join("..")
.join("..")
.join(".hidden_folder"),
),
];
#[cfg(windows)]
{
let target_dir = format!("cd {dir_str}/../../");
let slash_suggestions = completer.complete(&target_dir, target_dir.len());
let expected_slash_paths: Vec<String> = expected_paths
.iter()
.map(|s| s.replace('\\', "/"))
.collect();
match_suggestions(&expected_slash_paths, &slash_suggestions);
}
// Match the results
match_suggestions(&expected_paths, &suggestions);
}
#[test] #[test]
fn variables_completions() { fn variables_completions() {
// Create a new engine // Create a new engine

View File

@ -152,6 +152,17 @@ mod test_expand_ndots {
}; };
assert_path_eq!(expand_ndots(path), expected); assert_path_eq!(expand_ndots(path), expected);
} }
#[test]
fn trailing_dots() {
let path = Path::new("/foo/bar/..");
let expected = if cfg!(windows) {
r"\foo\bar\.."
} else {
"/foo/bar/.."
};
assert_path_eq!(expand_ndots(path), expected);
}
} }
#[cfg(test)] #[cfg(test)]