Nebula Terminal 0.2.1
This commit is contained in:
commit
6f104206b7
349 files changed
+59988
No files matched your search
@@ -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"] }
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user