Files
Kigi-CLI/crates/codegen/kigi-shell/src/extensions/suggest/file_provider.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
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).
2026-07-17 05:31:01 -04:00

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()
);
}
}