Skip to main content

cli_ui/
complete.rs

1//! Runtime helpers for smart shell completions.
2//!
3//! These functions run **at completion time** inside the compiled binary when
4//! the shell calls `app --complete --flag <word>`.  They are never invoked
5//! during normal program execution.
6//!
7//! # Dynamic completion protocol
8//!
9//! The generated shell scripts use a two-phase strategy:
10//!
11//! **Static** (baked into the script at compile time):
12//! - Flag names, bool flags, `one_of` value lists, range hints.
13//!
14//! **Dynamic** (called at completion time):
15//! - Filesystem queries filtered by extension / directory.
16//! - User-supplied `complete = fn_name` providers.
17//!
18//! The shell script calls:
19//! ```text
20//! app --complete --flag <current-word>
21//! ```
22//! The app detects `--complete`, dispatches to the right handler, prints
23//! one candidate per line to **stdout**, then exits 0.  The shell captures
24//! that output as the completion list.
25
26use std::path::Path;
27
28// ─────────────────────────────────────────────────────────────────────────────
29// File completion
30// ─────────────────────────────────────────────────────────────────────────────
31
32/// List files under the directory implied by `prefix`, keeping only those
33/// whose extension is in `exts` (case-insensitive, without the dot).
34///
35/// - `prefix`    — what the user has typed so far (may include a dir part).
36/// - `exts`      — allowed extensions, e.g. `["csv", "json"]`. Empty = any.
37/// - `strip_ext` — when `true`, the extension is removed from the suggestion
38///   (cargo style: `--example foo` instead of `foo.rs`).
39///
40/// Directories are always suggested so the user can drill into them.
41pub fn complete_files_with_ext(prefix: &str, exts: &[&str], strip_ext: bool) -> Vec<String> {
42    let (dir, file_prefix) = split_prefix(prefix);
43    let search = if dir.is_empty() {
44        Path::new(".")
45    } else {
46        Path::new(&dir)
47    };
48
49    let Ok(rd) = std::fs::read_dir(search) else {
50        return Vec::new();
51    };
52
53    let mut out = Vec::new();
54    for entry in rd.flatten() {
55        let name = entry.file_name();
56        let name = name.to_string_lossy();
57        if !name.starts_with(&*file_prefix) {
58            continue;
59        }
60
61        let path = entry.path();
62        if path.is_dir() {
63            let full = join_prefix(&dir, &format!("{}/", name));
64            out.push(full);
65            continue;
66        }
67
68        if !exts.is_empty() {
69            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
70            if !exts.iter().any(|&e| e.eq_ignore_ascii_case(ext)) {
71                continue;
72            }
73        }
74
75        let display = if strip_ext {
76            path.file_stem()
77                .and_then(|s| s.to_str())
78                .unwrap_or(&name)
79                .to_string()
80        } else {
81            name.to_string()
82        };
83
84        out.push(join_prefix(&dir, &display));
85    }
86    out.sort();
87    out
88}
89
90/// List only directories matching `prefix`.
91pub fn complete_dirs(prefix: &str) -> Vec<String> {
92    let (dir, file_prefix) = split_prefix(prefix);
93    let search = if dir.is_empty() {
94        Path::new(".")
95    } else {
96        Path::new(&dir)
97    };
98
99    let Ok(rd) = std::fs::read_dir(search) else {
100        return Vec::new();
101    };
102
103    let mut out: Vec<String> = rd
104        .flatten()
105        .filter(|e| e.path().is_dir())
106        .map(|e| e.file_name().to_string_lossy().to_string())
107        .filter(|n| n.starts_with(&*file_prefix))
108        .map(|n| join_prefix(&dir, &format!("{n}/")))
109        .collect();
110    out.sort();
111    out
112}
113
114/// List all files (no extension filter).
115pub fn complete_files(prefix: &str) -> Vec<String> {
116    complete_files_with_ext(prefix, &[], false)
117}
118
119/// Infer allowed extensions from a glob pattern (`"*.csv"`, `"*.{rs,toml}"`)
120/// and delegate to [`complete_files_with_ext`].
121pub fn complete_files_from_glob(prefix: &str, pattern: &str) -> Vec<String> {
122    let exts = extract_exts_from_glob(pattern);
123    let refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect();
124    complete_files_with_ext(prefix, &refs, false)
125}
126
127// ─────────────────────────────────────────────────────────────────────────────
128// Internal helpers
129// ─────────────────────────────────────────────────────────────────────────────
130
131/// Split `"./src/ma"` → `("./src", "ma")`.  `"src/"` → `("src", "")`.
132fn split_prefix(prefix: &str) -> (String, String) {
133    if prefix.is_empty() {
134        return (String::new(), String::new());
135    }
136    if prefix.ends_with('/') {
137        return (prefix.trim_end_matches('/').to_string(), String::new());
138    }
139    let p = std::path::Path::new(prefix);
140    let dir = p
141        .parent()
142        .map(|d| d.to_string_lossy().to_string())
143        .unwrap_or_default();
144    let file = p
145        .file_name()
146        .map(|f| f.to_string_lossy().to_string())
147        .unwrap_or_default();
148    (dir, file)
149}
150
151fn join_prefix(dir: &str, name: &str) -> String {
152    if dir.is_empty() || dir == "." {
153        name.to_string()
154    } else {
155        format!("{}/{}", dir.trim_end_matches('/'), name)
156    }
157}
158
159fn extract_exts_from_glob(pattern: &str) -> Vec<String> {
160    if let Some(dot) = pattern.rfind('.') {
161        let after = &pattern[dot + 1..];
162        if after.starts_with('{') && after.ends_with('}') {
163            return after[1..after.len() - 1]
164                .split(',')
165                .map(|s| s.trim().to_string())
166                .collect();
167        }
168        if !after.chars().any(|c| "*?[]{".contains(c)) {
169            return vec![after.to_string()];
170        }
171    }
172    Vec::new()
173}
174
175// ─────────────────────────────────────────────────────────────────────────────
176// Output helpers
177// ─────────────────────────────────────────────────────────────────────────────
178
179/// Print one completion candidate per line to stdout.
180pub fn print_values(values: &[String]) {
181    for v in values {
182        println!("{v}");
183    }
184}
185
186/// Print one completion candidate per line, filtered by `word` prefix.
187pub fn print_values_filtered(values: &[&str], word: &str) {
188    for v in values {
189        if v.starts_with(word) {
190            println!("{v}");
191        }
192    }
193}