Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1148 lines
43 KiB
Rust
1148 lines
43 KiB
Rust
//! Filesystem completion for the shell token under the cursor: any
|
|
//! command's file arguments (plus path-like first tokens and redirection
|
|
//! targets), with fuzzy matching, shell quoting, and `~`/`$VAR` awareness.
|
|
//!
|
|
//! Token *syntax* — the minimal tokenizer and the re-quoting rules, with
|
|
//! their documented limits — lives in [`super::shell_token`]. This module
|
|
//! owns the completion *policy*: which tokens complete, directory
|
|
//! listing/ranking, and `~`/`$VAR` expansion. Expansion only picks the
|
|
//! directory to LIST (quotes do not suppress it: `'$HOME'/x` lists like
|
|
//! `$HOME/x`); the inserted completion always preserves the user's typed
|
|
//! prefix verbatim.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
|
|
|
|
use super::shell_token::{CurrentToken, build_insert_token, parse_current_token};
|
|
use super::{RankedSuggestion, SuggestContext, SuggestionSource, splice_token_into_line};
|
|
|
|
/// Ranked results returned per request. The dropdown renders 6 rows and
|
|
/// scrolls; ranking happens BEFORE this cap so directories and the best
|
|
/// fuzzy matches survive it.
|
|
const MAX_RESULTS: usize = 50;
|
|
|
|
/// Directory-scan cap guarding pathological directories (the same guard the
|
|
/// `/export` path completer uses).
|
|
const SCAN_CAP: usize = 1000;
|
|
|
|
/// Symlink-classification stat budget per scan: a directory of up to
|
|
/// [`SCAN_CAP`] symlinks would otherwise serialize that many `stat`s
|
|
/// (hundreds of ms locally, worse on network filesystems). Past the budget
|
|
/// a symlink classifies as a file — worst case a symlinked directory loses
|
|
/// its trailing `/` and dirs-first ranking.
|
|
const SYMLINK_STAT_BUDGET: usize = 64;
|
|
|
|
/// Commands whose file arguments get a small ranking BOOST — not a gate:
|
|
/// any command's arguments file-complete. Must stay sorted (binary_search).
|
|
const FILE_COMMANDS: &[&str] = &[
|
|
"awk", "bat", "cat", "cd", "chmod", "chown", "code", "cp", "diff", "file", "find", "grep",
|
|
"head", "less", "ln", "ls", "mkdir", "mv", "nano", "nvim", "rm", "sed", "sort", "source",
|
|
"stat", "tail", "touch", "vi", "vim", "wc",
|
|
];
|
|
|
|
/// Priority bump for candidates when the segment's command is a known file
|
|
/// consumer: above $PATH rows (priority 0) AND above the history tail
|
|
/// (history base decays to 1 by list position — boosted file rows
|
|
/// deliberately displace the weakest history matches); below mid/top
|
|
/// history rows (base up to 10, +30 exact).
|
|
///
|
|
/// Every candidate in one response carries the SAME priority: ordering
|
|
/// within the response is provider-internal (tier → score → dirs-first →
|
|
/// name) and survives to the wire only because `aggregate`'s sort is
|
|
/// STABLE (see `mod.rs`).
|
|
const FILE_CMD_BOOST: i32 = 2;
|
|
|
|
pub(crate) struct FilePathProvider;
|
|
|
|
impl FilePathProvider {
|
|
pub async fn suggest(&self, ctx: &SuggestContext) -> Vec<RankedSuggestion> {
|
|
// shell_token quoting is POSIX-only: cmd/pwsh would misparse the
|
|
// escaped line, so Windows serves no deterministic completions.
|
|
if cfg!(windows) {
|
|
return Vec::new();
|
|
}
|
|
let tok = match extract_file_context(ctx.prefix()) {
|
|
Some(t) => t,
|
|
None => return Vec::new(),
|
|
};
|
|
// Completions replace the whole quoted/escaped token up to the cursor.
|
|
let arg_range = (tok.start, ctx.prefix().len());
|
|
let split = split_token(
|
|
&tok,
|
|
&ctx.text,
|
|
&ctx.cwd,
|
|
dirs::home_dir().as_deref(),
|
|
|name| std::env::var(name).ok(),
|
|
);
|
|
let (entries, truncated) = list_ranked_entries(&split.list_dir, split.match_prefix).await;
|
|
let boost = file_command_boost(tok.command.as_deref());
|
|
|
|
let mut results: Vec<RankedSuggestion> = entries
|
|
.into_iter()
|
|
.map(|e| RankedSuggestion {
|
|
// Token replacement for the arg range: the completed path,
|
|
// re-quoted to match how the user opened the token.
|
|
insert_text: build_insert_token(&tok, &split.raw_dir, &e.name, e.is_dir),
|
|
display: if e.is_dir {
|
|
format!("{}/", e.name)
|
|
} else {
|
|
e.name
|
|
},
|
|
description: if e.is_dir {
|
|
"directory".into()
|
|
} else {
|
|
String::new()
|
|
},
|
|
source: SuggestionSource::File,
|
|
priority: boost,
|
|
is_ghost_candidate: false,
|
|
replace_range: Some(arg_range),
|
|
token_text: None,
|
|
truncated,
|
|
})
|
|
.collect();
|
|
splice_token_into_line(&mut results, &ctx.text, arg_range);
|
|
results
|
|
}
|
|
}
|
|
|
|
/// Decide whether the token under the cursor file-completes:
|
|
/// - flag-looking tokens (`-x`, `--foo`) never do;
|
|
/// - any command's arguments (non-first tokens) and redirection targets do;
|
|
/// - a first token only when path-like (`./script.sh`, `/bin/…`, `~`) —
|
|
/// plain first words are the $PATH provider's turf.
|
|
fn extract_file_context(prefix: &str) -> Option<CurrentToken> {
|
|
let tok = parse_current_token(prefix);
|
|
if tok.value.starts_with('-') {
|
|
return None;
|
|
}
|
|
if tok.tokens_before == 0 && !tok.after_redirect && !is_path_like(&tok.value) {
|
|
return None;
|
|
}
|
|
Some(tok)
|
|
}
|
|
|
|
fn is_path_like(s: &str) -> bool {
|
|
s.contains('/') || s == "~"
|
|
}
|
|
|
|
// ── Directory/prefix split + `~`/`$VAR` expansion (listing only) ────────
|
|
|
|
struct SplitToken<'a> {
|
|
/// Expanded directory to list (absolute, or joined onto the cwd).
|
|
list_dir: PathBuf,
|
|
/// Final path component typed so far (unquoted) — the match needle.
|
|
match_prefix: &'a str,
|
|
/// Verbatim request-text slice kept in front of every completion, so
|
|
/// the user's own quoting/escapes/`~`/`$VAR` spellings survive.
|
|
raw_dir: String,
|
|
}
|
|
|
|
fn split_token<'a>(
|
|
tok: &'a CurrentToken,
|
|
text: &str,
|
|
cwd: &str,
|
|
home: Option<&Path>,
|
|
lookup: impl Fn(&str) -> Option<String>,
|
|
) -> SplitToken<'a> {
|
|
// Bare `~` completes as `~/…`: list the home directory. With NO
|
|
// resolvable home, `~` stays literal — exactly what the shell's own
|
|
// failed tilde expansion does — so list `cwd/~` (usually nothing) like
|
|
// the `~/x` arm below. Falling back to listing the cwd itself would
|
|
// show files the accepted `~/…` insert can never name. A quoted or
|
|
// escaped `~` is shell-literal — the general path matches it against
|
|
// cwd entries instead.
|
|
if tok.value == "~" && tok.dir_value_len.is_none() && tok.plain_mask.first() == Some(&true) {
|
|
return SplitToken {
|
|
list_dir: home.map_or_else(|| Path::new(cwd).join("~"), Path::to_path_buf),
|
|
match_prefix: "",
|
|
raw_dir: "~/".to_owned(),
|
|
};
|
|
}
|
|
let split_at = tok.dir_value_len.unwrap_or(0);
|
|
let expanded = expand_for_listing(
|
|
&tok.value[..split_at],
|
|
&tok.plain_mask[..split_at],
|
|
home,
|
|
lookup,
|
|
);
|
|
let list_dir = if expanded.as_os_str().is_empty() {
|
|
PathBuf::from(cwd)
|
|
} else if expanded.is_absolute() {
|
|
expanded
|
|
} else {
|
|
Path::new(cwd).join(expanded)
|
|
};
|
|
SplitToken {
|
|
list_dir,
|
|
match_prefix: &tok.value[split_at..],
|
|
raw_dir: text[tok.start..tok.dir_raw_end].to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Expand `~/` and `$VAR`/`${VAR}` in the directory part, for LISTING only
|
|
/// and only where the shell itself would: `plain` (byte-aligned with
|
|
/// `dir_value`) marks chars typed unquoted and unescaped, so `'$HOME'/x`,
|
|
/// `\$HOME/x`, and `"~/x` stay literal. Deliberately conservative:
|
|
/// double-quoted `$VAR`, which bash would expand, stays literal too.
|
|
/// Unset variables and `~user` forms stay literal (the listing just comes
|
|
/// up empty); the inserted text never contains the expansion.
|
|
fn expand_for_listing(
|
|
dir_value: &str,
|
|
plain: &[bool],
|
|
home: Option<&Path>,
|
|
lookup: impl Fn(&str) -> Option<String>,
|
|
) -> PathBuf {
|
|
// Tilde first (always at the word start), vars on the remainder — with
|
|
// its own mask slice, so provenance stays byte-aligned after the home
|
|
// prefix replaces `~`.
|
|
if let (Some(rest), Some(h)) = (dir_value.strip_prefix("~/"), home)
|
|
&& plain.first() == Some(&true)
|
|
{
|
|
return PathBuf::from(format!(
|
|
"{}/{}",
|
|
h.to_string_lossy(),
|
|
expand_vars(rest, &plain[2..], lookup)
|
|
));
|
|
}
|
|
PathBuf::from(expand_vars(dir_value, plain, lookup))
|
|
}
|
|
|
|
/// Replace `$NAME` / `${NAME}` with `lookup(NAME)` when set; anything else
|
|
/// (unset vars, a lone `$`, `$1`-style digits, or a `$` the user quoted or
|
|
/// escaped — `plain` is byte-aligned with `s`) stays literal.
|
|
fn expand_vars(s: &str, plain: &[bool], lookup: impl Fn(&str) -> Option<String>) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut rest = s;
|
|
while let Some(pos) = rest.find('$') {
|
|
out.push_str(&rest[..pos]);
|
|
if plain.get(s.len() - rest.len() + pos) != Some(&true) {
|
|
out.push('$');
|
|
rest = &rest[pos + 1..];
|
|
continue;
|
|
}
|
|
let after = &rest[pos + 1..];
|
|
// `(name, token_len)`: bytes consumed starting at the `$`.
|
|
let (name, token_len) = match after.strip_prefix('{') {
|
|
Some(inner) => match inner.find('}') {
|
|
Some(end) => (&inner[..end], 2 + end + 1),
|
|
None => ("", 1),
|
|
},
|
|
None => {
|
|
let end = after
|
|
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
|
.unwrap_or(after.len());
|
|
(&after[..end], 1 + end)
|
|
}
|
|
};
|
|
let valid = !name.is_empty()
|
|
&& !name.starts_with(|c: char| c.is_ascii_digit())
|
|
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
|
|
if !valid {
|
|
out.push('$');
|
|
rest = &rest[pos + 1..];
|
|
continue;
|
|
}
|
|
match lookup(name) {
|
|
Some(v) => out.push_str(&v),
|
|
None => out.push_str(&rest[pos..pos + token_len]),
|
|
}
|
|
rest = &rest[pos + token_len..];
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
// ── Matching + ranking ──────────────────────────────────────────────────
|
|
|
|
struct ScoredEntry {
|
|
name: String,
|
|
is_dir: bool,
|
|
/// 0 = exact prefix, 1 = case-insensitive prefix, 2 = fuzzy.
|
|
tier: u8,
|
|
score: u32,
|
|
}
|
|
|
|
/// List `dir` and rank matches: exact-prefix, then case-insensitive prefix,
|
|
/// then nucleo fuzzy — score descending, directories first, name ascending
|
|
/// within ties. Ranking happens BEFORE the [`MAX_RESULTS`] cap so an
|
|
/// alphabetical scan order can never crowd directories or better matches
|
|
/// out. Hidden entries only list when the typed prefix starts with `.`.
|
|
///
|
|
/// The second return is `truncated`: the scan hit [`SCAN_CAP`] or the
|
|
/// ranked matches exceeded [`MAX_RESULTS`] — the returned set may be
|
|
/// incomplete, so the pager must not conclude from it (no insta-accept or
|
|
/// LCP fill over rows a hidden entry could disprove).
|
|
async fn list_ranked_entries(dir: &Path, match_prefix: &str) -> (Vec<ScoredEntry>, bool) {
|
|
let mut read_dir = match tokio::fs::read_dir(dir).await {
|
|
Ok(rd) => rd,
|
|
Err(_) => return (Vec::new(), false),
|
|
};
|
|
|
|
let show_hidden = match_prefix.starts_with('.');
|
|
let prefix_lower = match_prefix.to_lowercase();
|
|
let atom = (!match_prefix.is_empty()).then(|| {
|
|
Atom::new(
|
|
match_prefix,
|
|
CaseMatching::Ignore,
|
|
Normalization::Smart,
|
|
AtomKind::Fuzzy,
|
|
false,
|
|
)
|
|
});
|
|
let mut config = nucleo::Config::DEFAULT;
|
|
config.prefer_prefix = true;
|
|
let mut matcher = nucleo::Matcher::new(config);
|
|
let mut buf = Vec::new();
|
|
|
|
let mut entries: Vec<ScoredEntry> = Vec::new();
|
|
let mut scanned = 0usize;
|
|
let mut symlink_stats = 0usize;
|
|
let mut truncated = false;
|
|
loop {
|
|
scanned += 1;
|
|
if scanned > SCAN_CAP {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
let entry = match read_dir.next_entry().await {
|
|
Ok(Some(entry)) => entry,
|
|
Ok(None) => break,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let name = match entry.file_name().into_string() {
|
|
Ok(n) => n,
|
|
Err(_) => continue,
|
|
};
|
|
if name.starts_with('.') && !show_hidden {
|
|
continue;
|
|
}
|
|
|
|
let (tier, score) = match match_prefix.is_empty() {
|
|
true => (0, 0),
|
|
false => {
|
|
let score = atom
|
|
.as_ref()
|
|
.and_then(|a| a.score(nucleo::Utf32Str::new(&name, &mut buf), &mut matcher))
|
|
.map(u32::from);
|
|
if name.starts_with(match_prefix) {
|
|
(0, score.unwrap_or(0))
|
|
} else if ci_starts_with(&name, &prefix_lower) {
|
|
(1, score.unwrap_or(0))
|
|
} else {
|
|
match score {
|
|
Some(s) => (2, s),
|
|
None => continue,
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// `file_type()` is free on most Unix (it comes from the dirent);
|
|
// only symlinks need the full `stat` — following them keeps the
|
|
// trailing `/` on symlinked directories — and those stats are
|
|
// budgeted (see [`SYMLINK_STAT_BUDGET`]).
|
|
let is_dir = match entry.file_type().await {
|
|
Ok(ft) if ft.is_symlink() && symlink_stats < SYMLINK_STAT_BUDGET => {
|
|
symlink_stats += 1;
|
|
tokio::fs::metadata(entry.path())
|
|
.await
|
|
.map(|m| m.is_dir())
|
|
.unwrap_or(false)
|
|
}
|
|
Ok(ft) => ft.is_dir(),
|
|
Err(_) => false,
|
|
};
|
|
|
|
entries.push(ScoredEntry {
|
|
name,
|
|
is_dir,
|
|
tier,
|
|
score,
|
|
});
|
|
}
|
|
|
|
entries.sort_unstable_by(|a, b| {
|
|
a.tier
|
|
.cmp(&b.tier)
|
|
.then_with(|| b.score.cmp(&a.score))
|
|
.then_with(|| b.is_dir.cmp(&a.is_dir))
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
});
|
|
truncated |= entries.len() > MAX_RESULTS;
|
|
entries.truncate(MAX_RESULTS);
|
|
(entries, truncated)
|
|
}
|
|
|
|
fn file_command_boost(command: Option<&str>) -> i32 {
|
|
let Some(cmd) = command else { return 0 };
|
|
let base = cmd.rsplit('/').next().unwrap_or(cmd);
|
|
if FILE_COMMANDS.binary_search(&base).is_ok() {
|
|
FILE_CMD_BOOST
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
/// Case-insensitive prefix test without a per-entry `to_lowercase`
|
|
/// allocation (`prefix_lower` is lowered once per request). Char-fold
|
|
/// equivalent of `name.to_lowercase().starts_with(prefix_lower)`.
|
|
fn ci_starts_with(name: &str, prefix_lower: &str) -> bool {
|
|
let mut folded = name.chars().flat_map(char::to_lowercase);
|
|
prefix_lower.chars().all(|p| folded.next() == Some(p))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// --- extract_file_context (completion decision) ---
|
|
|
|
#[test]
|
|
fn context_file_cmd_with_arg() {
|
|
let tok = extract_file_context("cat foo").unwrap();
|
|
assert_eq!(tok.value, "foo");
|
|
assert_eq!(tok.start, 4);
|
|
assert_eq!(tok.tokens_before, 1);
|
|
assert_eq!(tok.command.as_deref(), Some("cat"));
|
|
}
|
|
|
|
/// ANY command's argument file-completes — FILE_COMMANDS is only a
|
|
/// ranking boost, never a gate.
|
|
#[test]
|
|
fn context_unknown_cmd_plain_arg_completes() {
|
|
let tok = extract_file_context("git status").unwrap();
|
|
assert_eq!(tok.value, "status");
|
|
assert_eq!(tok.start, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn context_flag_tokens_never_complete() {
|
|
assert!(extract_file_context("ls -l").is_none());
|
|
assert!(extract_file_context("git --ver").is_none());
|
|
assert!(extract_file_context("cat -").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn context_trailing_space_any_cmd() {
|
|
let tok = extract_file_context("cat ").unwrap();
|
|
assert_eq!(tok.value, "");
|
|
assert_eq!(tok.start, 4);
|
|
let tok = extract_file_context("git ").unwrap();
|
|
assert_eq!(tok.start, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn context_empty_and_whitespace_only() {
|
|
assert!(extract_file_context("").is_none());
|
|
assert!(extract_file_context(" ").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn context_first_token_plain_word_no_activate() {
|
|
assert!(extract_file_context("hello").is_none());
|
|
assert!(extract_file_context("cat").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn context_first_token_path_like_completes() {
|
|
let tok = extract_file_context("./src/main").unwrap();
|
|
assert_eq!(tok.value, "./src/main");
|
|
assert_eq!(tok.start, 0);
|
|
assert!(extract_file_context("/usr/bin/ca").is_some());
|
|
assert!(extract_file_context("~").is_some());
|
|
}
|
|
|
|
/// Redirection targets are file arguments even at command position
|
|
/// (the parse mechanics live in shell_token's redirect test).
|
|
#[test]
|
|
fn context_redirect_target_completes() {
|
|
assert!(extract_file_context("echo hi > lo").is_some());
|
|
assert!(extract_file_context("> lo").is_some());
|
|
}
|
|
|
|
// --- is_path_like ---
|
|
|
|
#[test]
|
|
fn path_like_matrix() {
|
|
assert!(is_path_like("/usr/bin"));
|
|
assert!(is_path_like("./src"));
|
|
assert!(is_path_like("../lib"));
|
|
assert!(is_path_like("src/main.rs"));
|
|
assert!(is_path_like("~"));
|
|
assert!(!is_path_like("hello"));
|
|
assert!(!is_path_like(""));
|
|
assert!(!is_path_like("."));
|
|
assert!(!is_path_like(".."));
|
|
assert!(!is_path_like("~user"));
|
|
}
|
|
|
|
// --- split_token + expansion ---
|
|
|
|
fn no_vars(_: &str) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
fn token_for(text: &str) -> CurrentToken {
|
|
extract_file_context(text).expect("token")
|
|
}
|
|
|
|
#[test]
|
|
fn split_absolute_path() {
|
|
let tok = token_for("cat /usr/bin/gi");
|
|
let s = split_token(&tok, "cat /usr/bin/gi", "/home", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/usr/bin/"));
|
|
assert_eq!(s.match_prefix, "gi");
|
|
assert_eq!(s.raw_dir, "/usr/bin/");
|
|
}
|
|
|
|
#[test]
|
|
fn split_relative_path() {
|
|
let tok = token_for("cat src/main");
|
|
let s = split_token(&tok, "cat src/main", "/home/user", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/home/user/src/"));
|
|
assert_eq!(s.match_prefix, "main");
|
|
assert_eq!(s.raw_dir, "src/");
|
|
}
|
|
|
|
#[test]
|
|
fn split_no_slash_uses_cwd() {
|
|
let tok = token_for("cat foo");
|
|
let s = split_token(&tok, "cat foo", "/tmp", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/tmp"));
|
|
assert_eq!(s.match_prefix, "foo");
|
|
assert_eq!(s.raw_dir, "");
|
|
}
|
|
|
|
#[test]
|
|
fn split_trailing_slash_lists_that_dir() {
|
|
let tok = token_for("cat src/");
|
|
let s = split_token(&tok, "cat src/", "/home", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/home/src/"));
|
|
assert_eq!(s.match_prefix, "");
|
|
assert_eq!(s.raw_dir, "src/");
|
|
}
|
|
|
|
#[test]
|
|
fn split_tilde_expands_home_for_listing_only() {
|
|
let tok = token_for("cat ~/Do");
|
|
let s = split_token(
|
|
&tok,
|
|
"cat ~/Do",
|
|
"/ignored",
|
|
Some(Path::new("/home/me")),
|
|
no_vars,
|
|
);
|
|
assert_eq!(s.list_dir, PathBuf::from("/home/me/"));
|
|
assert_eq!(s.match_prefix, "Do");
|
|
// The insert prefix keeps the user's `~/`, never the expansion.
|
|
assert_eq!(s.raw_dir, "~/");
|
|
}
|
|
|
|
#[test]
|
|
fn split_bare_tilde_lists_home_as_tilde_slash() {
|
|
let tok = token_for("cat ~");
|
|
let s = split_token(
|
|
&tok,
|
|
"cat ~",
|
|
"/ignored",
|
|
Some(Path::new("/home/me")),
|
|
no_vars,
|
|
);
|
|
assert_eq!(s.list_dir, PathBuf::from("/home/me"));
|
|
assert_eq!(s.match_prefix, "");
|
|
assert_eq!(s.raw_dir, "~/");
|
|
}
|
|
|
|
/// With NO resolvable home, bare `~` stays literal — the shell's own
|
|
/// failed tilde expansion leaves the word unchanged — so the listing is
|
|
/// `cwd/~` (usually empty), NEVER the cwd itself: cwd entries would be
|
|
/// unreachable through the `~/…` insert. Degrades identically to the
|
|
/// `~/x` arm, whose listing is pinned alongside.
|
|
#[test]
|
|
fn split_bare_tilde_without_home_stays_literal() {
|
|
let tok = token_for("cat ~");
|
|
let s = split_token(&tok, "cat ~", "/work", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/work/~"));
|
|
assert_eq!(s.match_prefix, "");
|
|
assert_eq!(s.raw_dir, "~/");
|
|
|
|
let tok = token_for("cat ~/Do");
|
|
let s = split_token(&tok, "cat ~/Do", "/work", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/work/~"), "same literal dir");
|
|
assert_eq!(s.match_prefix, "Do");
|
|
assert_eq!(s.raw_dir, "~/");
|
|
}
|
|
|
|
#[test]
|
|
fn split_var_expands_for_listing_keeps_raw_insert() {
|
|
let tok = token_for("cat $MYDIR/fi");
|
|
let lookup = |name: &str| (name == "MYDIR").then(|| "/data/stuff".to_owned());
|
|
let s = split_token(&tok, "cat $MYDIR/fi", "/ignored", None, lookup);
|
|
assert_eq!(s.list_dir, PathBuf::from("/data/stuff/"));
|
|
assert_eq!(s.match_prefix, "fi");
|
|
assert_eq!(s.raw_dir, "$MYDIR/");
|
|
}
|
|
|
|
#[test]
|
|
fn split_quoted_dir_keeps_verbatim_raw() {
|
|
let tok = token_for("cat \"My Dir\"/fi");
|
|
let s = split_token(&tok, "cat \"My Dir\"/fi", "/home", None, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/home/My Dir/"));
|
|
assert_eq!(s.match_prefix, "fi");
|
|
assert_eq!(s.raw_dir, "\"My Dir\"/");
|
|
}
|
|
|
|
/// Quoted/escaped `$VAR` spellings are shell-literal: the listing must
|
|
/// target the literal path (never the expansion) — otherwise the
|
|
/// accepted raw-spelling insert names a different file than the one
|
|
/// shown.
|
|
#[test]
|
|
fn split_quoted_and_escaped_var_stay_literal() {
|
|
let lookup = |name: &str| (name == "HOME").then(|| "/home/me".to_owned());
|
|
|
|
let tok = token_for("cat '$HOME'/fi");
|
|
let s = split_token(&tok, "cat '$HOME'/fi", "/work", None, lookup);
|
|
assert_eq!(s.list_dir, PathBuf::from("/work/$HOME/"));
|
|
assert_eq!(s.match_prefix, "fi");
|
|
assert_eq!(s.raw_dir, "'$HOME'/");
|
|
|
|
let tok = token_for("cat \\$HOME/fi");
|
|
let s = split_token(&tok, "cat \\$HOME/fi", "/work", None, lookup);
|
|
assert_eq!(s.list_dir, PathBuf::from("/work/$HOME/"));
|
|
assert_eq!(s.raw_dir, "\\$HOME/");
|
|
}
|
|
|
|
/// Quoted `~` is shell-literal: no home listing — the same literal
|
|
/// `cwd/~` degradation as the no-home case; a bare quoted `~` skips the
|
|
/// home fast path and matches cwd entries literally.
|
|
#[test]
|
|
fn split_quoted_tilde_stays_literal() {
|
|
let home = Some(Path::new("/home/me"));
|
|
|
|
let tok = token_for("cat \"~/do");
|
|
let s = split_token(&tok, "cat \"~/do", "/work", home, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/work/~"));
|
|
assert_eq!(s.match_prefix, "do");
|
|
assert_eq!(s.raw_dir, "\"~/");
|
|
|
|
let tok = token_for("cat '~'");
|
|
let s = split_token(&tok, "cat '~'", "/work", home, no_vars);
|
|
assert_eq!(s.list_dir, PathBuf::from("/work"));
|
|
assert_eq!(s.match_prefix, "~");
|
|
assert_eq!(s.raw_dir, "");
|
|
}
|
|
|
|
fn all_plain(s: &str) -> Vec<bool> {
|
|
vec![true; s.len()]
|
|
}
|
|
|
|
#[test]
|
|
fn expand_vars_matrix() {
|
|
let lookup = |name: &str| match name {
|
|
"HOME" => Some("/home/me".to_owned()),
|
|
"A_1" => Some("x".to_owned()),
|
|
_ => None,
|
|
};
|
|
let ev = |s: &str| expand_vars(s, &all_plain(s), lookup);
|
|
assert_eq!(ev("$HOME/docs"), "/home/me/docs");
|
|
assert_eq!(ev("${HOME}/docs"), "/home/me/docs");
|
|
assert_eq!(ev("$A_1/z"), "x/z");
|
|
// Unset vars stay literal.
|
|
assert_eq!(ev("$NOPE/docs"), "$NOPE/docs");
|
|
// Lone `$`, digits, and malformed braces stay literal.
|
|
assert_eq!(ev("a$"), "a$");
|
|
assert_eq!(ev("$1/x"), "$1/x");
|
|
assert_eq!(ev("${/x"), "${/x");
|
|
}
|
|
|
|
/// A `$` whose byte is not plain (quoted/escaped provenance) stays
|
|
/// literal even when the variable is set.
|
|
#[test]
|
|
fn expand_vars_skips_non_plain_dollar() {
|
|
let lookup = |name: &str| (name == "HOME").then(|| "/home/me".to_owned());
|
|
let s = "$HOME/$HOME";
|
|
let mut plain = all_plain(s);
|
|
plain[0] = false;
|
|
assert_eq!(expand_vars(s, &plain, lookup), "$HOME//home/me");
|
|
}
|
|
|
|
// --- file_command_boost ---
|
|
|
|
#[test]
|
|
fn boost_for_known_file_commands_only() {
|
|
assert_eq!(file_command_boost(Some("cat")), FILE_CMD_BOOST);
|
|
assert_eq!(file_command_boost(Some("/bin/cat")), FILE_CMD_BOOST);
|
|
assert_eq!(file_command_boost(Some("git")), 0);
|
|
assert_eq!(file_command_boost(None), 0);
|
|
}
|
|
|
|
/// `file_command_boost` binary-searches: a misplaced insert would
|
|
/// silently drop boosts for everything after it.
|
|
#[test]
|
|
fn file_commands_is_sorted() {
|
|
assert!(FILE_COMMANDS.is_sorted());
|
|
}
|
|
|
|
#[test]
|
|
fn ci_starts_with_matches_lowercase_semantics() {
|
|
assert!(ci_starts_with("Notes Archive", "no"));
|
|
assert!(ci_starts_with("notes.md", "no"));
|
|
assert!(!ci_starts_with("anode", "no"));
|
|
assert!(!ci_starts_with("N", "no"));
|
|
// Multibyte fold: uppercase e-acute lowers to the 2-byte e-acute.
|
|
assert!(ci_starts_with("\u{c9}tude", "\u{e9}t"));
|
|
}
|
|
|
|
// --- list_ranked_entries ---
|
|
|
|
#[tokio::test]
|
|
async fn rank_exact_then_ci_prefix_then_fuzzy() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("note.md"), "").unwrap();
|
|
std::fs::write(tmp.path().join("Nope.txt"), "").unwrap();
|
|
std::fs::write(tmp.path().join("anode.txt"), "").unwrap();
|
|
|
|
let names: Vec<String> = list_ranked_entries(tmp.path(), "no")
|
|
.await
|
|
.0
|
|
.into_iter()
|
|
.map(|e| e.name)
|
|
.collect();
|
|
assert_eq!(names, ["note.md", "Nope.txt", "anode.txt"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_case_insensitive_prefix_matches() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::create_dir(tmp.path().join("Notes Archive")).unwrap();
|
|
|
|
let (entries, _) = list_ranked_entries(tmp.path(), "no").await;
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].name, "Notes Archive");
|
|
assert_eq!(entries[0].tier, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_fuzzy_subsequence_matches() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("notes.md"), "").unwrap();
|
|
std::fs::write(tmp.path().join("zzz.bin"), "").unwrap();
|
|
|
|
let (entries, _) = list_ranked_entries(tmp.path(), "nts").await;
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].name, "notes.md");
|
|
assert_eq!(entries[0].tier, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_no_match_is_empty() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("alpha"), "").unwrap();
|
|
assert!(list_ranked_entries(tmp.path(), "qqq").await.0.is_empty());
|
|
}
|
|
|
|
/// Directories survive the result cap even when the scan yields them
|
|
/// after `MAX_RESULTS` files (ranking happens before truncation).
|
|
#[tokio::test]
|
|
async fn rank_dirs_first_before_cap() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
for i in 0..60 {
|
|
std::fs::write(tmp.path().join(format!("file_{i:03}")), "").unwrap();
|
|
}
|
|
// `z`-names sort after every file alphabetically.
|
|
std::fs::create_dir(tmp.path().join("zdir_a")).unwrap();
|
|
std::fs::create_dir(tmp.path().join("zdir_b")).unwrap();
|
|
|
|
let (entries, truncated) = list_ranked_entries(tmp.path(), "").await;
|
|
assert_eq!(entries.len(), MAX_RESULTS);
|
|
assert!(truncated, "result-capped scans are not exhaustive");
|
|
assert!(entries[0].is_dir && entries[1].is_dir);
|
|
assert_eq!(entries[0].name, "zdir_a");
|
|
assert_eq!(entries[1].name, "zdir_b");
|
|
assert!(entries[2..].iter().all(|e| !e.is_dir));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_deterministic_name_order_within_tier() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("zebra"), "").unwrap();
|
|
std::fs::write(tmp.path().join("alpha"), "").unwrap();
|
|
std::fs::write(tmp.path().join("mango"), "").unwrap();
|
|
|
|
let names: Vec<String> = list_ranked_entries(tmp.path(), "")
|
|
.await
|
|
.0
|
|
.into_iter()
|
|
.map(|e| e.name)
|
|
.collect();
|
|
assert_eq!(names, ["alpha", "mango", "zebra"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_skips_hidden_without_dot_prefix() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join(".hidden"), "").unwrap();
|
|
std::fs::write(tmp.path().join("visible"), "").unwrap();
|
|
|
|
let (entries, truncated) = list_ranked_entries(tmp.path(), "").await;
|
|
assert_eq!(entries.len(), 1);
|
|
assert!(!truncated, "an exhaustively scanned dir is not truncated");
|
|
assert_eq!(entries[0].name, "visible");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_shows_hidden_with_dot_prefix() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join(".config"), "").unwrap();
|
|
std::fs::write(tmp.path().join(".hidden"), "").unwrap();
|
|
std::fs::write(tmp.path().join("visible"), "").unwrap();
|
|
|
|
let (entries, _) = list_ranked_entries(tmp.path(), ".").await;
|
|
assert_eq!(entries.len(), 2);
|
|
assert!(entries.iter().all(|e| e.name.starts_with('.')));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rank_nonexistent_and_empty_dirs() {
|
|
assert!(
|
|
list_ranked_entries(Path::new("/no/such/dir"), "")
|
|
.await
|
|
.0
|
|
.is_empty()
|
|
);
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
assert!(list_ranked_entries(tmp.path(), "").await.0.is_empty());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn rank_follows_symlinked_dir() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let real_dir = tmp.path().join("real");
|
|
std::fs::create_dir(&real_dir).unwrap();
|
|
std::os::unix::fs::symlink(&real_dir, tmp.path().join("link")).unwrap();
|
|
|
|
let (entries, _) = list_ranked_entries(tmp.path(), "li").await;
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].name, "link");
|
|
assert!(entries[0].is_dir);
|
|
}
|
|
|
|
/// A self-referential symlink makes the follow-up `metadata()` fail
|
|
/// (ELOOP) — the entry still lists, as a non-directory.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn rank_self_symlink_falls_back_to_file_type() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::os::unix::fs::symlink("selfie", tmp.path().join("selfie")).unwrap();
|
|
|
|
let (entries, _) = list_ranked_entries(tmp.path(), "se").await;
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].name, "selfie");
|
|
assert!(!entries[0].is_dir);
|
|
}
|
|
|
|
/// A scan that hits [`SCAN_CAP`] reports truncation: entries may remain
|
|
/// unread, so the returned rows must not be treated as exhaustive.
|
|
#[tokio::test]
|
|
async fn rank_scan_cap_marks_truncated() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
for i in 0..=SCAN_CAP {
|
|
std::fs::write(tmp.path().join(format!("f_{i:04}")), "").unwrap();
|
|
}
|
|
|
|
let (entries, truncated) = list_ranked_entries(tmp.path(), "").await;
|
|
assert!(truncated);
|
|
assert_eq!(entries.len(), MAX_RESULTS);
|
|
}
|
|
|
|
// --- end-to-end via suggest() ---
|
|
|
|
fn ctx(text: &str, cwd: &Path) -> SuggestContext {
|
|
SuggestContext::new(
|
|
text.to_owned(),
|
|
text.len(),
|
|
cwd.to_string_lossy().into_owned(),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suggest_end_to_end_file_cmd() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("hello.txt"), "").unwrap();
|
|
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
|
|
|
|
let results = FilePathProvider.suggest(&ctx("cat hel", tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].display, "hello.txt");
|
|
assert_eq!(results[0].insert_text, "cat hello.txt");
|
|
assert_eq!(results[0].token_text.as_deref(), Some("hello.txt"));
|
|
assert_eq!(results[0].replace_range, Some((4, 7)));
|
|
assert_eq!(results[0].source, SuggestionSource::File);
|
|
assert!(!results[0].is_ghost_candidate);
|
|
assert!(!results[0].truncated);
|
|
assert_eq!(results[0].priority, FILE_CMD_BOOST);
|
|
}
|
|
|
|
/// A capped scan stamps `truncated` on every row — the pager must not
|
|
/// insta-accept a "sole" match a hidden entry could disprove.
|
|
#[tokio::test]
|
|
async fn suggest_capped_scan_stamps_truncated_rows() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
for i in 0..=MAX_RESULTS {
|
|
std::fs::write(tmp.path().join(format!("note_{i:03}")), "").unwrap();
|
|
}
|
|
|
|
let results = FilePathProvider.suggest(&ctx("cat no", tmp.path())).await;
|
|
assert_eq!(results.len(), MAX_RESULTS);
|
|
assert!(results.iter().all(|s| s.truncated));
|
|
}
|
|
|
|
/// THE closed-quote round trip (cursor right after the closer): the
|
|
/// completed insert keeps the closing quote instead of dropping it.
|
|
#[tokio::test]
|
|
async fn suggest_quote_closed_at_cursor_keeps_closer() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let dir = tmp.path().join("My Dir");
|
|
std::fs::create_dir(&dir).unwrap();
|
|
std::fs::write(dir.join("file.txt"), "").unwrap();
|
|
|
|
let text = "cat \"My Dir/fi\"";
|
|
let results = FilePathProvider.suggest(&ctx(text, tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(
|
|
results[0].token_text.as_deref(),
|
|
Some("\"My Dir/file.txt\"")
|
|
);
|
|
assert_eq!(results[0].insert_text, "cat \"My Dir/file.txt\"");
|
|
assert_eq!(results[0].replace_range, Some((4, text.len())));
|
|
}
|
|
|
|
/// Unknown commands complete their args too — at priority 0, no boost.
|
|
#[tokio::test]
|
|
async fn suggest_end_to_end_any_command() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("notes.md"), "").unwrap();
|
|
|
|
let results = FilePathProvider
|
|
.suggest(&ctx("foobar no", tmp.path()))
|
|
.await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].insert_text, "foobar notes.md");
|
|
assert_eq!(results[0].priority, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suggest_flag_token_returns_nothing() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("-weird"), "").unwrap();
|
|
assert!(
|
|
FilePathProvider
|
|
.suggest(&ctx("git -w", tmp.path()))
|
|
.await
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
/// Arg-token range with text after the cursor: the range ends at the
|
|
/// cursor so the tail (`| wc -l`) is out of the replaced span, and the
|
|
/// compat whole-line `insert_text` keeps that tail too.
|
|
#[tokio::test]
|
|
async fn suggest_arg_range_ends_at_cursor_mid_text() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("hello.txt"), "").unwrap();
|
|
|
|
let text = "cat hel | wc -l";
|
|
let ctx = SuggestContext::new(text.into(), 7, tmp.path().to_string_lossy().into_owned());
|
|
let results = FilePathProvider.suggest(&ctx).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].insert_text, "cat hello.txt | wc -l");
|
|
assert_eq!(results[0].token_text.as_deref(), Some("hello.txt"));
|
|
assert_eq!(results[0].replace_range, Some((4, 7)));
|
|
}
|
|
|
|
/// THE quoting round-trip: `cat "My Fi` completes `My File.txt` with the
|
|
/// token extent covering the opening quote and the insert closing it.
|
|
#[tokio::test]
|
|
async fn suggest_open_quote_completes_spaced_file() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("My File.txt"), "").unwrap();
|
|
|
|
let text = "cat \"My Fi";
|
|
let results = FilePathProvider.suggest(&ctx(text, tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].token_text.as_deref(), Some("\"My File.txt\""));
|
|
assert_eq!(results[0].replace_range, Some((4, text.len())));
|
|
assert_eq!(results[0].insert_text, "cat \"My File.txt\"");
|
|
}
|
|
|
|
/// Unquoted completion of a spaced name backslash-escapes it; the next
|
|
/// request tokenizes that insert back to the same directory (round-trip).
|
|
#[tokio::test]
|
|
async fn suggest_unquoted_spaced_dir_escapes_and_drills_down() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let dir = tmp.path().join("Notes Archive");
|
|
std::fs::create_dir(&dir).unwrap();
|
|
std::fs::write(dir.join("inner.txt"), "").unwrap();
|
|
|
|
let results = FilePathProvider.suggest(&ctx("cat No", tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].token_text.as_deref(), Some("Notes\\ Archive/"));
|
|
assert_eq!(results[0].insert_text, "cat Notes\\ Archive/");
|
|
assert_eq!(results[0].description, "directory");
|
|
|
|
// Drill-down: the accepted text re-completes inside the directory.
|
|
let text = "cat Notes\\ Archive/";
|
|
let results = FilePathProvider.suggest(&ctx(text, tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(
|
|
results[0].token_text.as_deref(),
|
|
Some("Notes\\ Archive/inner.txt")
|
|
);
|
|
assert_eq!(results[0].replace_range, Some((4, text.len())));
|
|
assert_eq!(results[0].insert_text, "cat Notes\\ Archive/inner.txt");
|
|
}
|
|
|
|
/// Same drill-down through an open double quote: the dir insert keeps
|
|
/// the quote open; the file completion inside closes it.
|
|
#[tokio::test]
|
|
async fn suggest_quoted_dir_drill_down_closes_quote_on_file() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let dir = tmp.path().join("Notes Archive");
|
|
std::fs::create_dir(&dir).unwrap();
|
|
std::fs::write(dir.join("inner.txt"), "").unwrap();
|
|
|
|
let results = FilePathProvider.suggest(&ctx("cat \"No", tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].token_text.as_deref(), Some("\"Notes Archive/"));
|
|
|
|
let text = "cat \"Notes Archive/";
|
|
let results = FilePathProvider.suggest(&ctx(text, tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(
|
|
results[0].token_text.as_deref(),
|
|
Some("\"Notes Archive/inner.txt\"")
|
|
);
|
|
assert_eq!(results[0].replace_range, Some((4, text.len())));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suggest_nested_dir_range_and_insert() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let sub = tmp.path().join("src");
|
|
std::fs::create_dir(&sub).unwrap();
|
|
std::fs::write(sub.join("main.rs"), "").unwrap();
|
|
|
|
let results = FilePathProvider
|
|
.suggest(&ctx("cat src/m", tmp.path()))
|
|
.await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].token_text.as_deref(), Some("src/main.rs"));
|
|
assert_eq!(results[0].replace_range, Some((4, 9)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suggest_end_to_end_path_like_first_token() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let sub = tmp.path().join("src");
|
|
std::fs::create_dir(&sub).unwrap();
|
|
std::fs::write(sub.join("main.rs"), "").unwrap();
|
|
|
|
let text = format!("{}/src/m", tmp.path().display());
|
|
let cursor = text.len();
|
|
let ctx = SuggestContext::new(text, cursor, "/ignored".into());
|
|
let results = FilePathProvider.suggest(&ctx).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].display, "main.rs");
|
|
assert_eq!(results[0].replace_range, Some((0, cursor)));
|
|
assert!(
|
|
results[0]
|
|
.token_text
|
|
.as_deref()
|
|
.unwrap()
|
|
.ends_with("main.rs")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suggest_exact_prefix_ranks_before_case_insensitive() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("notes.md"), "").unwrap();
|
|
std::fs::create_dir(tmp.path().join("Notes Archive")).unwrap();
|
|
|
|
let results = FilePathProvider.suggest(&ctx("cat no", tmp.path())).await;
|
|
assert_eq!(results.len(), 2);
|
|
assert_eq!(results[0].display, "notes.md");
|
|
assert_eq!(results[1].display, "Notes Archive/");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn suggest_var_prefix_lists_expansion_inserts_raw() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("notes.md"), "").unwrap();
|
|
|
|
let _env = kigi_test_support::EnvGuard::set("KIGI_SUGGEST_TEST_DIR", tmp.path());
|
|
let ctx = SuggestContext::new(
|
|
"cat $KIGI_SUGGEST_TEST_DIR/no".into(),
|
|
"cat $KIGI_SUGGEST_TEST_DIR/no".len(),
|
|
"/ignored".into(),
|
|
);
|
|
let results = FilePathProvider.suggest(&ctx).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(
|
|
results[0].token_text.as_deref(),
|
|
Some("$KIGI_SUGGEST_TEST_DIR/notes.md")
|
|
);
|
|
}
|
|
|
|
/// THE provenance case: a quoted `$VAR` is literal to the shell, so the
|
|
/// listing targets a directory literally named `$HOME` — the accepted
|
|
/// candidate names exactly the file shown (no env expansion, whatever
|
|
/// the real `$HOME` is).
|
|
#[tokio::test]
|
|
async fn suggest_quoted_var_lists_literal_dir() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let lit = tmp.path().join("$HOME");
|
|
std::fs::create_dir(&lit).unwrap();
|
|
std::fs::write(lit.join("file.txt"), "").unwrap();
|
|
|
|
let results = FilePathProvider
|
|
.suggest(&ctx("cat '$HOME'/fi", tmp.path()))
|
|
.await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].token_text.as_deref(), Some("'$HOME'/file.txt"));
|
|
assert_eq!(results[0].insert_text, "cat '$HOME'/file.txt");
|
|
}
|
|
|
|
/// A dash-leading candidate inserts `./`-anchored, so single-candidate
|
|
/// insta-accept can never silently write a flag (`rm ` + Tab must not
|
|
/// become `rm -rf`).
|
|
#[tokio::test]
|
|
async fn suggest_dash_leading_candidate_anchored_as_path() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("-rf"), "").unwrap();
|
|
|
|
let results = FilePathProvider.suggest(&ctx("rm ", tmp.path())).await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].display, "-rf");
|
|
assert_eq!(results[0].token_text.as_deref(), Some("./-rf"));
|
|
assert_eq!(results[0].insert_text, "rm ./-rf");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suggest_first_token_plain_word_no_results() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("hello"), "").unwrap();
|
|
assert!(
|
|
FilePathProvider
|
|
.suggest(&ctx("hel", tmp.path()))
|
|
.await
|
|
.is_empty()
|
|
);
|
|
}
|
|
}
|