Nebula Terminal 0.2.1

This commit is contained in:
kuddev committed 2026-07-11 13:04:59 +08:00
commit 6f104206b7
349 files changed
+59988

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
[package]
name = "nebula-completions"
version = "0.2.1"
edition.workspace = true
[features]
default = ["color"]
color = ["dep:nu-ansi-term", "dep:lscolors"]
[dependencies]
nucleo-matcher = "0.3"
unicase = "2.9"
unicode-segmentation = "1.13"
nu-ansi-term = { version = "0.50", optional = true, default-features = false }
lscolors = { version = "0.21", optional = true, default-features = false, features = ["nu-ansi-term"] }
+9
View File
@@ -0,0 +1,9 @@
/// Optional LS_COLORS support.
/// Get an `lscolors::LsColors` instance from an optional env var value.
pub(crate) fn get_ls_colors(env: Option<&str>) -> Option<lscolors::LsColors> {
Some(env.map_or_else(
lscolors::LsColors::default,
lscolors::LsColors::from_string,
))
}
+23
View File
@@ -0,0 +1,23 @@
use crate::options::CompletionOptions;
use crate::span::Span;
use crate::suggestion::SemanticSuggestion;
/// Trait for types that can produce completion suggestions.
pub trait Completer {
/// Fetch, filter, and sort completions for the given `prefix`.
///
/// * `cwd` — Current working directory (used by file-based completers).
/// * `prefix` — The partial text the user has typed.
/// * `span` — The span in the original input that `prefix` covers.
/// * `offset` — Offset of the span relative to the start of the line
/// (for adjusting span values in suggestions).
/// * `options` — Matching / sorting configuration.
fn fetch(
&mut self,
cwd: &str,
prefix: impl AsRef<str>,
span: Span,
offset: usize,
options: &CompletionOptions,
) -> Vec<SemanticSuggestion>;
}
+58
View File
@@ -0,0 +1,58 @@
use crate::completer::Completer;
use crate::file::complete_item;
use crate::options::CompletionOptions;
use crate::span::Span;
use crate::suggestion::{SemanticSuggestion, Suggestion, SuggestionKind};
/// A completer that returns only directory entries.
pub struct DirectoryCompletion;
impl Completer for DirectoryCompletion {
fn fetch(
&mut self,
cwd: &str,
prefix: impl AsRef<str>,
span: Span,
offset: usize,
options: &CompletionOptions,
) -> Vec<SemanticSuggestion> {
let prefix = prefix.as_ref();
let items = complete_item(true, span, prefix, &[cwd], options, true, None);
let current_span = Span::new(
span.start.saturating_sub(offset),
span.end.saturating_sub(offset),
);
let mut hidden = Vec::new();
let mut non_hidden = Vec::new();
for x in items {
let sugg = SemanticSuggestion {
suggestion: Suggestion {
value: x.path,
#[cfg(feature = "color")]
style: x.style,
span: current_span,
display_override: x.display_override,
match_indices: Some(x.match_indices),
..Suggestion::default()
},
kind: Some(SuggestionKind::Directory),
};
let item_path = std::path::Path::new(&sugg.suggestion.value);
if let Some(val) = item_path.file_name().and_then(|v| v.to_str()) {
if val.starts_with('.') {
hidden.push(sugg);
} else {
non_hidden.push(sugg);
}
}
}
non_hidden.append(&mut hidden);
non_hidden
}
}
+472
View File
@@ -0,0 +1,472 @@
use std::path::{Component, Path, PathBuf, MAIN_SEPARATOR as SEP, is_separator};
use std::fmt::Write;
use unicode_segmentation::UnicodeSegmentation;
use crate::options::{CompletionOptions, MatchAlgorithm};
use crate::matcher::{CandidateMatcher, IgnoreCaseExt};
use crate::span::Span;
// ---------------------------------------------------------------------------
// FileSuggestion
// ---------------------------------------------------------------------------
/// A single file/directory completion candidate produced by [`complete_item`].
pub struct FileSuggestion {
pub span: Span,
pub path: String,
#[cfg(feature = "color")]
pub style: Option<nu_ansi_term::Style>,
pub is_dir: bool,
pub display_override: Option<String>,
pub match_indices: Vec<usize>,
}
// ---------------------------------------------------------------------------
// Helper types for recursive path completion
// ---------------------------------------------------------------------------
#[derive(Clone, Default)]
struct PathBuiltFromString {
cwd: PathBuf,
parts: Vec<MatchedPart>,
isdir: bool,
}
#[derive(Clone, Default)]
struct MatchedPart {
text: String,
match_indices: Vec<usize>,
}
#[derive(Debug)]
enum OriginalCwd {
None,
Home,
Prefix(String),
}
/// Expand n-dots (`...` → `../..`, `....` → `../../..`, etc.).
fn expand_ndots(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(&c) = chars.peek() {
if c == '.' {
let mut dot_count = 0;
while chars.peek() == Some(&'.') {
chars.next();
dot_count += 1;
}
if dot_count > 2 {
// n dots → n-1 repetitions of ".."
for i in 0..dot_count - 1 {
if i > 0 {
result.push(SEP);
}
result.push_str("..");
}
} else {
for _ in 0..dot_count {
result.push('.');
}
}
} else {
result.push(chars.next().unwrap());
}
}
PathBuf::from(result)
}
/// Collapse consecutive `..` segments back into n-dots.
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
}
/// Recursively walk directories and collect matching paths.
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 = CandidateMatcher::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: Vec<usize> =
(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));
}
}
}
// Single exact match → drill into it directly (hides sibling entries)
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()
}
}
/// Escape special characters in a path for safe insertion.
///
/// Returns `Some(escaped)` if escaping was needed, `None` if the path
/// is safe as-is.
pub fn escape_path(path: &str) -> Option<String> {
// Check for glob-like characters or backticks
let has_glob = path.contains(|c: char| matches!(c, '*' | '?' | '[' | ']'));
if has_glob || path.contains('`') {
// Simple tilde expansion
let expanded = if path.starts_with('~') {
dirs_next_home().map_or_else(
|| path.to_string(),
|home| {
let rest = &path[1..];
if rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\') {
format!("{}{}", home.display(), rest)
} else {
// ~user form — just leave it
path.to_string()
}
},
)
} else {
path.to_string()
};
if expanded.contains('\'') {
Some(format!("{expanded:?}")) // debug escapes quotes
} else {
Some(format!("'{expanded}'"))
}
} 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
}
}
}
/// Get the home directory (small helper to avoid pulling in `dirs` crate
/// if we can use `std`).
fn dirs_next_home() -> Option<PathBuf> {
// Try standard env vars first, then fall back to home_dir (deprecated but works)
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOMEDRIVE").and_then(|drive| {
std::env::var_os("HOMEPATH").map(|path| {
let mut p = PathBuf::from(drive);
p.push(path);
p
})
}))
}
/// Remove surrounding quotes from a partial path.
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()
}
/// Complete files / directories recursively.
///
/// * `want_directory` — only return directories if `true`.
/// * `span` — span of the partial in the input.
/// * `partial` — the partial path the user typed.
/// * `cwds` — one or more directories to search from.
/// * `options` — matching configuration.
/// * `use_ls_colors` — whether to compute ANSI styles from `LS_COLORS`.
/// * `ls_colors_env` — optional `LS_COLORS` environment variable value.
#[allow(unused_variables)]
pub fn complete_item(
want_directory: bool,
span: Span,
partial: &str,
cwds: &[impl AsRef<str>],
options: &CompletionOptions,
use_ls_colors: bool,
ls_colors_env: Option<&str>,
) -> 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);
// Handle trailing dot case
if cleaned_partial.ends_with(&format!("{path_separator}.")) {
write!(partial, "{path_separator}.").expect("write to String is infallible");
}
let cwd_pathbufs: Vec<_> = cwds
.iter()
.map(|cwd| Path::new(cwd.as_ref()).to_path_buf())
.collect();
#[cfg(feature = "color")]
let ls_colors = if use_ls_colors {
crate::color::get_ls_colors(ls_colors_env)
} else {
None
};
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(..)) => {
// Windows prefix (e.g., `C:`)
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 = dirs_next_home()
.map(|dir| vec![dir])
.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);
}
#[cfg(feature = "color")]
let style = ls_colors.as_ref().and_then(|lsc| {
let real_path = std::path::absolute(&path).ok().unwrap_or_else(|| PathBuf::from(&path));
lsc.style_for_path_with_metadata(&real_path, None)
.map(|s| s.to_nu_ansi_term_style())
});
let (value, display_override) = if let Some(escaped) = escape_path(&path) {
(escaped, Some(path))
} else {
(path, None)
};
FileSuggestion {
span,
path: value,
#[cfg(feature = "color")]
style,
is_dir,
display_override,
match_indices,
}
})
.collect()
}
+34
View File
@@ -0,0 +1,34 @@
//! `nebula-completions` — A lightweight, standalone completion engine.
//!
//! Extracted from Nushell's `nu-cli` completions framework, this crate
//! provides:
//!
//! * A [`CandidateMatcher`](matcher::CandidateMatcher) engine supporting
//! prefix, substring, and fuzzy matching via `nucleo-matcher`.
//! * A [`Completer`](completer::Completer) trait for pluggable completion
//! sources.
//! * Built-in completers for files, directories, and static string lists.
//! * `Span`, `Suggestion`, `SemanticSuggestion`, and `SuggestionKind` types.
pub mod completer;
pub mod file;
pub mod matcher;
pub mod options;
pub mod span;
pub mod suggestion;
mod directory;
mod static_completion;
#[cfg(feature = "color")]
mod color;
// -- Re-exports -----------------------------------------------------------
pub use completer::Completer;
pub use directory::DirectoryCompletion;
pub use matcher::{CandidateMatcher, IgnoreCaseExt};
pub use options::{CompletionOptions, CompletionSort, InvalidMatchAlgorithm, MatchAlgorithm};
pub use span::Span;
pub use static_completion::StaticCompletion;
pub use suggestion::{SemanticSuggestion, Suggestion, SuggestionKind};
+402
View File
@@ -0,0 +1,402 @@
use std::borrow::Cow;
use nucleo_matcher::{
Config, Matcher, Utf32Str,
pattern::{Atom, AtomKind, CaseMatching, Normalization},
};
use unicode_segmentation::UnicodeSegmentation;
use crate::options::{CompletionOptions, MatchAlgorithm};
use crate::suggestion::SemanticSuggestion;
// ---------------------------------------------------------------------------
// IgnoreCaseExt — case folding via unicase
// ---------------------------------------------------------------------------
pub trait IgnoreCaseExt {
/// Case-folded equivalent of this string (for comparison, not display).
fn to_folded_case(&self) -> String;
/// Case-insensitive equality check.
fn eq_ignore_case(&self, other: &str) -> bool;
}
impl IgnoreCaseExt for str {
fn to_folded_case(&self) -> String {
unicase::UniCase::new(self).to_folded_case()
}
fn eq_ignore_case(&self, other: &str) -> bool {
unicase::UniCase::new(self) == unicase::UniCase::new(other)
}
}
// ---------------------------------------------------------------------------
// CandidateMatcher — the core fuzzy / prefix / substring matching engine
// ---------------------------------------------------------------------------
const QUOTES: [char; 3] = ['"', '\'', '`'];
/// Internal data for an unscored (prefix / substring) match.
struct UnscoredMatch<T> {
item: T,
haystack: String,
match_indices: Vec<usize>,
}
/// Internal data for a fuzzy (scored) match.
struct FuzzyMatch<T> {
item: T,
haystack: String,
score: u16,
match_indices: Vec<usize>,
}
enum State<T> {
Unscored(Vec<UnscoredMatch<T>>),
Fuzzy {
matcher: Matcher,
atom: Atom,
matches: Vec<FuzzyMatch<T>>,
},
}
/// Filters and sorts candidate completions against a needle using the
/// configured [`MatchAlgorithm`].
///
/// Supports three algorithms:
/// * [`Prefix`](MatchAlgorithm::Prefix) — `starts_with`
/// * [`Substring`](MatchAlgorithm::Substring) — `contains`
/// * [`Fuzzy`](MatchAlgorithm::Fuzzy) — character-skip matching via `nucleo_matcher`
pub struct CandidateMatcher<'a, T> {
options: &'a CompletionOptions,
should_sort: bool,
needle: String,
state: State<T>,
}
impl<T> CandidateMatcher<'_, T> {
/// Create a new matcher.
///
/// * `needle` — The text to search for. Leading/trailing quotes are stripped.
/// * `options` — Matching configuration (algorithm, case sensitivity, etc.).
/// * `should_sort` — Whether results should be sorted before returning.
pub fn new(
needle: impl AsRef<str>,
options: &CompletionOptions,
should_sort: bool,
) -> CandidateMatcher<'_, T> {
let needle = needle.as_ref().trim_matches(QUOTES);
match options.match_algorithm {
MatchAlgorithm::Prefix | MatchAlgorithm::Substring => {
let lowercase_needle = if options.case_sensitive {
needle.to_owned()
} else {
needle.to_folded_case()
};
CandidateMatcher {
options,
should_sort,
needle: lowercase_needle,
state: State::Unscored(Vec::new()),
}
}
MatchAlgorithm::Fuzzy => {
let atom = Atom::new(
needle,
if options.case_sensitive {
CaseMatching::Respect
} else {
CaseMatching::Ignore
},
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
CandidateMatcher {
options,
should_sort,
needle: needle.to_owned(),
state: State::Fuzzy {
matcher: Matcher::new({
let mut cfg = Config::DEFAULT;
cfg.prefer_prefix = true;
cfg
}),
atom,
matches: Vec::new(),
},
}
}
}
}
/// Internal: test `haystack` against the needle and optionally store the item.
fn matches_aux(&mut self, orig_haystack: &str, item: Option<T>) -> Option<Vec<usize>> {
let haystack = orig_haystack.trim_start_matches(QUOTES);
let offset = orig_haystack.len() - haystack.len();
let haystack = haystack.trim_end_matches(QUOTES);
match &mut self.state {
State::Unscored(matches) => {
let haystack_folded = if self.options.case_sensitive {
Cow::Borrowed(haystack)
} else {
Cow::Owned(haystack.to_folded_case())
};
let match_start = match self.options.match_algorithm {
MatchAlgorithm::Prefix => {
if haystack_folded.starts_with(self.needle.as_str()) {
Some(0)
} else {
None
}
}
MatchAlgorithm::Substring => haystack_folded.find(self.needle.as_str()),
_ => unreachable!("Only prefix and substring algorithms don't use score"),
};
match_start.map(|byte_start| {
let grapheme_start = haystack_folded[0..byte_start].graphemes(true).count();
let grapheme_len = self.needle.graphemes(true).count();
let match_indices: Vec<usize> =
(offset + grapheme_start..offset + grapheme_start + grapheme_len).collect();
if let Some(item) = item {
matches.push(UnscoredMatch {
item,
haystack: haystack.to_string(),
match_indices: match_indices.clone(),
});
}
match_indices
})
}
State::Fuzzy {
matcher,
atom,
matches,
} => {
let mut haystack_buf = Vec::new();
let haystack_utf32 = Utf32Str::new(haystack, &mut haystack_buf);
let mut indices = Vec::new();
let score = atom.indices(haystack_utf32, matcher, &mut indices)?;
let indices: Vec<usize> = indices
.iter()
.map(|i| {
offset
+ usize::try_from(*i)
.expect("index should fit in usize on 32+ bit systems")
})
.collect();
if let Some(item) = item {
matches.push(FuzzyMatch {
item,
haystack: haystack.to_string(),
score,
match_indices: indices.clone(),
});
}
Some(indices)
}
}
}
/// Add `item` if `haystack` matches the needle.
///
/// Returns `true` if the item was added.
pub fn add(&mut self, haystack: impl AsRef<str>, item: T) -> bool {
self.matches_aux(haystack.as_ref(), Some(item)).is_some()
}
/// Check whether `haystack` matches the needle without inserting.
///
/// Returns `Some(match_indices)` if it matched.
pub fn check_match(&mut self, haystack: &str) -> Option<Vec<usize>> {
self.matches_aux(haystack, None)
}
fn sort(&mut self) {
match &mut self.state {
State::Unscored(matches) => {
matches.sort_by(|a, b| {
let cmp_sensitive = a.haystack.cmp(&b.haystack);
if self.options.case_sensitive {
cmp_sensitive
} else {
a.haystack
.to_folded_case()
.cmp(&b.haystack.to_folded_case())
.then(cmp_sensitive)
}
});
}
State::Fuzzy { matches, .. } => match self.options.sort {
crate::options::CompletionSort::Alphabetical => {
matches.sort_by(|a, b| a.haystack.cmp(&b.haystack));
}
crate::options::CompletionSort::Smart => {
matches.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then(a.haystack.cmp(&b.haystack))
});
}
},
}
}
/// Sort (if configured) and return all matched items with their match
/// indices.
pub fn results(mut self) -> Vec<(T, Vec<usize>)> {
if self.should_sort {
self.sort();
}
match self.state {
State::Unscored(matches) => matches
.into_iter()
.map(|m| (m.item, m.match_indices))
.collect(),
State::Fuzzy { matches, .. } => matches
.into_iter()
.map(|m| (m.item, m.match_indices))
.collect(),
}
}
}
// — SemanticSuggestion convenience helpers --------------------------------
impl CandidateMatcher<'_, SemanticSuggestion> {
/// Add a `SemanticSuggestion` (matched by its display value).
pub fn add_suggestion(&mut self, sugg: SemanticSuggestion) -> bool {
let value = sugg.suggestion.display_value().to_string();
self.add(value, sugg)
}
/// Return all matched suggestions sorted, with match indices set.
pub fn suggestion_results(self) -> Vec<SemanticSuggestion> {
self.results()
.into_iter()
.map(|(mut sugg, indices)| {
sugg.suggestion.match_indices = Some(indices);
sugg
})
.collect()
}
}
// ---------------------------------------------------------------------------
// Tests (ported from nushell `completion_options.rs`, all names stripped)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefix_match() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Prefix,
..Default::default()
};
let mut m = CandidateMatcher::new("examp", &opts, true);
assert!(m.add("example text", "example text"));
assert!(!m.add("text", "text"));
let results: Vec<_> = m.results().iter().map(|r| r.0).collect();
assert_eq!(vec!["example text"], results);
}
#[test]
fn prefix_no_match() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Prefix,
..Default::default()
};
let mut m = CandidateMatcher::new("text", &opts, true);
assert!(!m.add("example text", "example text"));
let results: Vec<_> = m.results().iter().map(|r| r.0).collect();
let empty: Vec<&str> = Vec::new();
assert_eq!(empty, results);
}
#[test]
fn substring_match() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Substring,
..Default::default()
};
let mut m = CandidateMatcher::new("text", &opts, true);
assert!(m.add("example text", "example text"));
let results: Vec<_> = m.results().iter().map(|r| r.0).collect();
assert_eq!(vec!["example text"], results);
}
#[test]
fn substring_no_match() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Substring,
..Default::default()
};
let mut m = CandidateMatcher::new("mplxt", &opts, true);
assert!(!m.add("example text", "example text"));
}
#[test]
fn fuzzy_match() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Fuzzy,
..Default::default()
};
let mut m = CandidateMatcher::new("ext", &opts, true);
assert!(m.add("example text", "example text"));
let results: Vec<_> = m.results().iter().map(|r| r.0).collect();
assert_eq!(vec!["example text"], results);
}
#[test]
fn fuzzy_no_match() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Fuzzy,
..Default::default()
};
let mut m = CandidateMatcher::new("mpp", &opts, true);
assert!(!m.add("example text", "example text"));
}
#[test]
fn fuzzy_sort_by_score() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Fuzzy,
sort: crate::options::CompletionSort::Smart,
..Default::default()
};
let mut m = CandidateMatcher::new("fob", &opts, true);
for item in ["foo/bar", "fob", "foo bar"] {
m.add(item, item);
}
let results = m.results();
assert_eq!(3, results.len());
// Best score first, alphabetical tie-break for equal scores
assert_eq!("fob", results[0].0);
assert_eq!("foo bar", results[1].0);
assert_eq!("foo/bar", results[2].0);
}
#[test]
fn fuzzy_strip_quotes() {
let opts = CompletionOptions {
match_algorithm: MatchAlgorithm::Fuzzy,
..Default::default()
};
let mut m = CandidateMatcher::new("'love spaces' ", &opts, true);
for item in [
"'i love spaces'",
"'i love spaces' so much",
"'lovespaces' ",
] {
m.add(item, item);
}
let results = m.results();
// Only "'i love spaces' so much" should match because the query
// includes a space after "spaces"
assert_eq!(1, results.len());
assert_eq!("'i love spaces' so much", results[0].0);
}
}
+77
View File
@@ -0,0 +1,77 @@
use std::fmt::Display;
/// Algorithm used to match completions against the input prefix.
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum MatchAlgorithm {
/// Only show suggestions beginning with the input.
///
/// Example: `"git switch"` is matched by `"git sw"`
Prefix,
/// Only show suggestions containing the input as a substring.
///
/// Example: `"git checkout"` is matched by `"checkout"`
Substring,
/// Fuzzy matching — characters can appear anywhere, in order.
///
/// Example: `"git checkout"` is matched by `"gco"`
Fuzzy,
}
/// Sorting strategy for completion results.
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub enum CompletionSort {
/// Sort alphabetically.
#[default]
Alphabetical,
/// Smart sort: by relevance score first, then alphabetically.
Smart,
}
/// Configuration for how completions are matched and sorted.
#[derive(Clone)]
pub struct CompletionOptions {
pub case_sensitive: bool,
pub match_algorithm: MatchAlgorithm,
pub sort: CompletionSort,
/// Whether to also match against the suggestion description.
pub match_description: bool,
}
impl Default for CompletionOptions {
fn default() -> Self {
Self {
case_sensitive: true,
match_algorithm: MatchAlgorithm::Prefix,
sort: CompletionSort::default(),
match_description: false,
}
}
}
#[derive(Debug)]
pub enum InvalidMatchAlgorithm {
Unknown,
}
impl Display for InvalidMatchAlgorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unknown match algorithm")
}
}
impl std::error::Error for InvalidMatchAlgorithm {}
impl TryFrom<String> for MatchAlgorithm {
type Error = InvalidMatchAlgorithm;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"prefix" => Ok(Self::Prefix),
"substring" => Ok(Self::Substring),
"fuzzy" => Ok(Self::Fuzzy),
_ => Err(InvalidMatchAlgorithm::Unknown),
}
}
}
+12
View File
@@ -0,0 +1,12 @@
/// A simple span representing a range in source text.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub const fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
}
@@ -0,0 +1,58 @@
use std::borrow::Cow;
use crate::completer::Completer;
use crate::matcher::CandidateMatcher;
use crate::options::CompletionOptions;
use crate::span::Span;
use crate::suggestion::{SemanticSuggestion, Suggestion, SuggestionKind};
/// A completer that matches against a static list of string options.
pub struct StaticCompletion {
options: Cow<'static, [String]>,
}
impl StaticCompletion {
/// Create a new completer from a static list of owned strings.
pub fn new(options: Cow<'static, [String]>) -> Self {
Self { options }
}
/// Create a completer from a `&'static [&'static str]` slice.
pub fn from_static(options: &'static [&'static str]) -> Self {
let v: Vec<String> = options.iter().map(|s| s.to_string()).collect();
Self {
options: Cow::Owned(v),
}
}
}
impl Completer for StaticCompletion {
fn fetch(
&mut self,
_cwd: &str,
prefix: impl AsRef<str>,
span: Span,
offset: usize,
options: &CompletionOptions,
) -> Vec<SemanticSuggestion> {
let mut matcher = CandidateMatcher::new(prefix, options, true);
let current_span = Span::new(
span.start.saturating_sub(offset),
span.end.saturating_sub(offset),
);
for option in self.options.iter() {
matcher.add_suggestion(SemanticSuggestion {
suggestion: Suggestion {
value: option.clone(),
span: current_span,
description: None,
..Suggestion::default()
},
kind: Some(SuggestionKind::Value("string".to_string())),
});
}
matcher.suggestion_results()
}
}
+101
View File
@@ -0,0 +1,101 @@
use crate::span::Span;
/// A completion suggestion, the core value-type for completions.
#[derive(Debug, Clone, PartialEq)]
pub struct Suggestion {
/// The text value to insert upon completion.
pub value: String,
/// Optional override for display (shown instead of `value`).
pub display_override: Option<String>,
/// Optional description / documentation.
pub description: Option<String>,
/// Arbitrary extra data.
pub extra: Option<String>,
/// Whether to append whitespace after the value.
pub append_whitespace: bool,
/// Indices in the displayed text that matched the query.
pub match_indices: Option<Vec<usize>>,
/// Styling for the suggestion.
#[cfg(feature = "color")]
pub style: Option<nu_ansi_term::Style>,
/// The span in the input that this suggestion replaces.
pub span: Span,
}
impl Default for Suggestion {
fn default() -> Self {
Self {
value: String::new(),
display_override: None,
description: None,
extra: None,
append_whitespace: true,
match_indices: None,
#[cfg(feature = "color")]
style: None,
span: Span::new(0, 0),
}
}
}
impl Suggestion {
/// The display value — either the override or the raw value.
pub fn display_value(&self) -> &str {
self.display_override.as_deref().unwrap_or(&self.value)
}
}
/// Categorizes the kind of a completion.
#[derive(Debug, Clone, PartialEq)]
pub enum SuggestionKind {
Command(String),
Value(String),
CellPath,
Directory,
File,
Flag,
Module,
Operator,
Variable,
}
/// A suggestion paired with its optional kind metadata.
#[derive(Debug, Clone, PartialEq)]
pub struct SemanticSuggestion {
pub suggestion: Suggestion,
pub kind: Option<SuggestionKind>,
}
impl SemanticSuggestion {
pub fn new(suggestion: Suggestion) -> Self {
Self {
suggestion,
kind: None,
}
}
pub fn with_kind(suggestion: Suggestion, kind: SuggestionKind) -> Self {
Self {
suggestion,
kind: Some(kind),
}
}
}
impl From<Suggestion> for SemanticSuggestion {
fn from(suggestion: Suggestion) -> Self {
Self {
suggestion,
..Default::default()
}
}
}
impl Default for SemanticSuggestion {
fn default() -> Self {
Self {
suggestion: Suggestion::default(),
kind: None,
}
}
}