mirror of
https://github.com/nushell/nushell.git
synced 2025-05-29 06:17:54 +02:00
<!-- if this PR closes one or more issues, you can automatically link the PR with them by using one of the [*linking keywords*](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword), e.g. - this PR should close #xxxx - fixes #xxxx you can also mention related issues, PRs or discussions! --> # Description <!-- Thank you for improving Nushell. Please, check our [contributing guide](../CONTRIBUTING.md) and talk to the core team before making major changes. Description of your pull request goes here. **Provide examples and/or screenshots** if your changes affect the user experience. --> This PR makes it so that when using fuzzy matching, the score isn't recomputed when sorting. Instead, filtering and sorting suggestions is handled by a new `NuMatcher` struct. This struct accepts suggestions and, if they match the user's typed text, stores those suggestions (along with their scores and values). At the end, it returns a sorted list of suggestions. This probably won't have a noticeable impact on performance, but it might be helpful if we start using Nucleo in the future. Minor change: Makes `find_commands_by_predicate` in `StateWorkingSet` and `EngineState` take `FnMut` rather than `Fn` for the predicate. # User-Facing Changes <!-- List of all changes that impact the user experience here. This helps us keep track of breaking changes. --> When using case-insensitive matching, if you have two matches `FOO` and `abc`, `abc` will be shown before `FOO` rather than the other way around. I think this way makes more sense than the current behavior. When I brought this up on Discord, WindSoilder did say it would make sense to show uppercase matches first if the user typed, say, `F`. However, that would be a lot more complicated to implement. # Tests + Formatting <!-- Don't forget to add tests that cover your changes. Make sure you've run and fixed any issues with these commands: - `cargo fmt --all -- --check` to check standard code formatting (`cargo fmt --all` applies these changes) - `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used` to check that you're using the standard code style - `cargo test --workspace` to check that all tests pass (on Windows make sure to [enable developer mode](https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging)) - `cargo run -- -c "use toolkit.nu; toolkit test stdlib"` to run the tests for the standard library > **Note** > from `nushell` you can also use the `toolkit` as follows > ```bash > use toolkit.nu # or use an `env_change` hook to activate it automatically > toolkit check pr > ``` --> Added a test for the changes in https://github.com/nushell/nushell/pull/13302. # After Submitting <!-- If your PR had any user-facing changes, update [the documentation](https://github.com/nushell/nushell.github.io) after the PR is merged, if necessary. This will help us keep the docs up to date. -->
339 lines
11 KiB
Rust
339 lines
11 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use crate::{
|
|
completions::{Completer, CompletionOptions},
|
|
SuggestionKind,
|
|
};
|
|
use nu_parser::FlatShape;
|
|
use nu_protocol::{
|
|
engine::{CachedFile, Stack, StateWorkingSet},
|
|
Span,
|
|
};
|
|
use reedline::Suggestion;
|
|
|
|
use super::{completion_options::NuMatcher, SemanticSuggestion};
|
|
|
|
pub struct CommandCompletion {
|
|
flattened: Vec<(Span, FlatShape)>,
|
|
flat_shape: FlatShape,
|
|
force_completion_after_space: bool,
|
|
}
|
|
|
|
impl CommandCompletion {
|
|
pub fn new(
|
|
flattened: Vec<(Span, FlatShape)>,
|
|
flat_shape: FlatShape,
|
|
force_completion_after_space: bool,
|
|
) -> Self {
|
|
Self {
|
|
flattened,
|
|
flat_shape,
|
|
force_completion_after_space,
|
|
}
|
|
}
|
|
|
|
fn external_command_completion(
|
|
&self,
|
|
working_set: &StateWorkingSet,
|
|
sugg_span: reedline::Span,
|
|
matched_internal: impl Fn(&str) -> bool,
|
|
matcher: &mut NuMatcher<String>,
|
|
) -> HashMap<String, SemanticSuggestion> {
|
|
let mut suggs = HashMap::new();
|
|
|
|
// os agnostic way to get the PATH env var
|
|
let paths = working_set.permanent_state.get_path_env_var();
|
|
|
|
if let Some(paths) = paths {
|
|
if let Ok(paths) = paths.as_list() {
|
|
for path in paths {
|
|
let path = path.coerce_str().unwrap_or_default();
|
|
|
|
if let Ok(mut contents) = std::fs::read_dir(path.as_ref()) {
|
|
while let Some(Ok(item)) = contents.next() {
|
|
if working_set
|
|
.permanent_state
|
|
.config
|
|
.completions
|
|
.external
|
|
.max_results
|
|
<= suggs.len() as i64
|
|
{
|
|
break;
|
|
}
|
|
let Ok(name) = item.file_name().into_string() else {
|
|
continue;
|
|
};
|
|
let value = if matched_internal(&name) {
|
|
format!("^{}", name)
|
|
} else {
|
|
name.clone()
|
|
};
|
|
if suggs.contains_key(&value) {
|
|
continue;
|
|
}
|
|
if matcher.matches(&name) && is_executable::is_executable(item.path()) {
|
|
// If there's an internal command with the same name, adds ^cmd to the
|
|
// matcher so that both the internal and external command are included
|
|
matcher.add(&name, value.clone());
|
|
suggs.insert(
|
|
value.clone(),
|
|
SemanticSuggestion {
|
|
suggestion: Suggestion {
|
|
value,
|
|
span: sugg_span,
|
|
append_whitespace: true,
|
|
..Default::default()
|
|
},
|
|
// TODO: is there a way to create a test?
|
|
kind: None,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
suggs
|
|
}
|
|
|
|
fn complete_commands(
|
|
&self,
|
|
working_set: &StateWorkingSet,
|
|
span: Span,
|
|
offset: usize,
|
|
find_externals: bool,
|
|
options: &CompletionOptions,
|
|
) -> Vec<SemanticSuggestion> {
|
|
let partial = working_set.get_span_contents(span);
|
|
let mut matcher = NuMatcher::new(String::from_utf8_lossy(partial), options.clone());
|
|
|
|
let sugg_span = reedline::Span::new(span.start - offset, span.end - offset);
|
|
|
|
let mut internal_suggs = HashMap::new();
|
|
let filtered_commands = working_set.find_commands_by_predicate(
|
|
|name| {
|
|
let name = String::from_utf8_lossy(name);
|
|
matcher.add(&name, name.to_string())
|
|
},
|
|
true,
|
|
);
|
|
for (name, description, typ) in filtered_commands {
|
|
let name = String::from_utf8_lossy(&name);
|
|
internal_suggs.insert(
|
|
name.to_string(),
|
|
SemanticSuggestion {
|
|
suggestion: Suggestion {
|
|
value: name.to_string(),
|
|
description,
|
|
span: sugg_span,
|
|
append_whitespace: true,
|
|
..Suggestion::default()
|
|
},
|
|
kind: Some(SuggestionKind::Command(typ)),
|
|
},
|
|
);
|
|
}
|
|
|
|
let mut external_suggs = if find_externals {
|
|
self.external_command_completion(
|
|
working_set,
|
|
sugg_span,
|
|
|name| internal_suggs.contains_key(name),
|
|
&mut matcher,
|
|
)
|
|
} else {
|
|
HashMap::new()
|
|
};
|
|
|
|
let mut res = Vec::new();
|
|
for cmd_name in matcher.results() {
|
|
if let Some(sugg) = internal_suggs
|
|
.remove(&cmd_name)
|
|
.or_else(|| external_suggs.remove(&cmd_name))
|
|
{
|
|
res.push(sugg);
|
|
}
|
|
}
|
|
res
|
|
}
|
|
}
|
|
|
|
impl Completer for CommandCompletion {
|
|
fn fetch(
|
|
&mut self,
|
|
working_set: &StateWorkingSet,
|
|
_stack: &Stack,
|
|
_prefix: &[u8],
|
|
span: Span,
|
|
offset: usize,
|
|
pos: usize,
|
|
options: &CompletionOptions,
|
|
) -> Vec<SemanticSuggestion> {
|
|
let last = self
|
|
.flattened
|
|
.iter()
|
|
.rev()
|
|
.skip_while(|x| x.0.end > pos)
|
|
.take_while(|x| {
|
|
matches!(
|
|
x.1,
|
|
FlatShape::InternalCall(_)
|
|
| FlatShape::External
|
|
| FlatShape::ExternalArg
|
|
| FlatShape::Literal
|
|
| FlatShape::String
|
|
)
|
|
})
|
|
.last();
|
|
|
|
// The last item here would be the earliest shape that could possible by part of this subcommand
|
|
let subcommands = if let Some(last) = last {
|
|
self.complete_commands(
|
|
working_set,
|
|
Span::new(last.0.start, pos),
|
|
offset,
|
|
false,
|
|
options,
|
|
)
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
if !subcommands.is_empty() {
|
|
return subcommands;
|
|
}
|
|
|
|
let config = working_set.get_config();
|
|
if matches!(self.flat_shape, nu_parser::FlatShape::External)
|
|
|| matches!(self.flat_shape, nu_parser::FlatShape::InternalCall(_))
|
|
|| ((span.end - span.start) == 0)
|
|
|| is_passthrough_command(working_set.delta.get_file_contents())
|
|
{
|
|
// we're in a gap or at a command
|
|
if working_set.get_span_contents(span).is_empty() && !self.force_completion_after_space
|
|
{
|
|
return vec![];
|
|
}
|
|
self.complete_commands(
|
|
working_set,
|
|
span,
|
|
offset,
|
|
config.completions.external.enable,
|
|
options,
|
|
)
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn find_non_whitespace_index(contents: &[u8], start: usize) -> usize {
|
|
match contents.get(start..) {
|
|
Some(contents) => {
|
|
contents
|
|
.iter()
|
|
.take_while(|x| x.is_ascii_whitespace())
|
|
.count()
|
|
+ start
|
|
}
|
|
None => start,
|
|
}
|
|
}
|
|
|
|
pub fn is_passthrough_command(working_set_file_contents: &[CachedFile]) -> bool {
|
|
for cached_file in working_set_file_contents {
|
|
let contents = &cached_file.content;
|
|
let last_pipe_pos_rev = contents.iter().rev().position(|x| x == &b'|');
|
|
let last_pipe_pos = last_pipe_pos_rev.map(|x| contents.len() - x).unwrap_or(0);
|
|
|
|
let cur_pos = find_non_whitespace_index(contents, last_pipe_pos);
|
|
|
|
let result = match contents.get(cur_pos..) {
|
|
Some(contents) => contents.starts_with(b"sudo ") || contents.starts_with(b"doas "),
|
|
None => false,
|
|
};
|
|
if result {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod command_completions_tests {
|
|
use super::*;
|
|
use nu_protocol::engine::EngineState;
|
|
use std::sync::Arc;
|
|
|
|
#[test]
|
|
fn test_find_non_whitespace_index() {
|
|
let commands = [
|
|
(" hello", 4),
|
|
("sudo ", 0),
|
|
(" sudo ", 2),
|
|
(" sudo ", 2),
|
|
(" hello ", 1),
|
|
(" hello ", 3),
|
|
(" hello | sudo ", 4),
|
|
(" sudo|sudo", 5),
|
|
("sudo | sudo ", 0),
|
|
(" hello sud", 1),
|
|
];
|
|
for (idx, ele) in commands.iter().enumerate() {
|
|
let index = find_non_whitespace_index(ele.0.as_bytes(), 0);
|
|
assert_eq!(index, ele.1, "Failed on index {}", idx);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_last_command_passthrough() {
|
|
let commands = [
|
|
(" hello", false),
|
|
(" sudo ", true),
|
|
("sudo ", true),
|
|
(" hello", false),
|
|
(" sudo", false),
|
|
(" sudo ", true),
|
|
(" sudo ", true),
|
|
(" sudo ", true),
|
|
(" hello ", false),
|
|
(" hello | sudo ", true),
|
|
(" sudo|sudo", false),
|
|
("sudo | sudo ", true),
|
|
(" hello sud", false),
|
|
(" sudo | sud ", false),
|
|
(" sudo|sudo ", true),
|
|
(" sudo | sudo ls | sudo ", true),
|
|
];
|
|
for (idx, ele) in commands.iter().enumerate() {
|
|
let input = ele.0.as_bytes();
|
|
|
|
let mut engine_state = EngineState::new();
|
|
engine_state.add_file("test.nu".into(), Arc::new([]));
|
|
|
|
let delta = {
|
|
let mut working_set = StateWorkingSet::new(&engine_state);
|
|
let _ = working_set.add_file("child.nu".into(), input);
|
|
working_set.render()
|
|
};
|
|
|
|
let result = engine_state.merge_delta(delta);
|
|
assert!(
|
|
result.is_ok(),
|
|
"Merge delta has failed: {}",
|
|
result.err().unwrap()
|
|
);
|
|
|
|
let is_passthrough_command = is_passthrough_command(engine_state.get_file_contents());
|
|
assert_eq!(
|
|
is_passthrough_command, ele.1,
|
|
"index for '{}': {}",
|
|
ele.0, idx
|
|
);
|
|
}
|
|
}
|
|
}
|