Skip to main content

zsh/zle/
computil_port.rs

1//! Completion utility functions for ZLE
2//!
3//! Port from zsh/Src/Zle/computil.c (5,180 lines)
4//!
5//! The full utility library is in compsys/computil.rs (674 lines).
6//! This module provides _describe, _values, _alternative, _combination,
7//! and the compdescribe/comparguments/compvalues builtins.
8//!
9//! Key C functions and their Rust locations:
10//! - bin_compdescribe  → compsys::describe::describe()
11//! - bin_comparguments → compsys::arguments (full _arguments)
12//! - bin_compvalues    → compsys::computil::compvalues()
13//! - bin_comptags      → compsys::state::comptags()
14//! - bin_comptry       → compsys::state::comptry()
15
16use std::collections::HashMap;
17
18/// Completion description set (from computil.c CDSet)
19#[derive(Debug, Clone)]
20pub struct CompDescSet {
21    pub tag: String,
22    pub group: String,
23    pub items: Vec<CompDescItem>,
24    pub options: DescOptions,
25}
26
27/// A single completion with description
28#[derive(Debug, Clone)]
29pub struct CompDescItem {
30    pub word: String,
31    pub description: String,
32    pub hidden: bool,
33}
34
35/// Options for _describe (from computil.c)
36#[derive(Debug, Clone, Default)]
37pub struct DescOptions {
38    pub verbose: bool,
39    pub sort: bool,
40    pub unique: bool,
41    pub group_name: Option<String>,
42    pub separator: String,
43}
44
45impl Default for CompDescSet {
46    fn default() -> Self {
47        CompDescSet {
48            tag: String::new(),
49            group: String::new(),
50            items: Vec::new(),
51            options: DescOptions {
52                separator: " -- ".to_string(),
53                ..Default::default()
54            },
55        }
56    }
57}
58
59/// Parse "word:description" format (from computil.c cd_get)
60pub fn cd_get(spec: &str) -> CompDescItem {
61    if let Some((word, desc)) = spec.split_once(':') {
62        CompDescItem {
63            word: word.to_string(),
64            description: desc.to_string(),
65            hidden: false,
66        }
67    } else {
68        CompDescItem {
69            word: spec.to_string(),
70            description: String::new(),
71            hidden: false,
72        }
73    }
74}
75
76/// Parse multiple specs into a description set (from computil.c cd_init)
77pub fn cd_init(specs: &[String], tag: &str, group: &str) -> CompDescSet {
78    let items: Vec<CompDescItem> = specs.iter().map(|s| cd_get(s)).collect();
79    CompDescSet {
80        tag: tag.to_string(),
81        group: group.to_string(),
82        items,
83        ..Default::default()
84    }
85}
86
87/// Sort items in a description set (from computil.c cd_sort)
88pub fn cd_sort(set: &mut CompDescSet) {
89    set.items.sort_by(|a, b| a.word.cmp(&b.word));
90}
91
92/// Calculate display widths (from computil.c cd_calc)
93pub fn cd_calc(items: &[CompDescItem], separator: &str) -> (usize, usize) {
94    let max_word = items.iter().map(|i| i.word.len()).max().unwrap_or(0);
95    let max_desc = items.iter().map(|i| i.description.len()).max().unwrap_or(0);
96    (max_word, max_word + separator.len() + max_desc)
97}
98
99/// Format items for display (from computil.c cd_prep)
100pub fn cd_prep(items: &[CompDescItem], separator: &str) -> Vec<String> {
101    let (max_word, _) = cd_calc(items, separator);
102    items
103        .iter()
104        .map(|item| {
105            if item.description.is_empty() {
106                item.word.clone()
107            } else {
108                format!(
109                    "{:<width$}{}{}",
110                    item.word,
111                    separator,
112                    item.description,
113                    width = max_word
114                )
115            }
116        })
117        .collect()
118}
119
120/// Check if groups want sorting (from computil.c cd_groups_want_sorting)
121pub fn cd_groups_want_sorting(sets: &[CompDescSet]) -> bool {
122    sets.iter().all(|s| s.options.sort)
123}
124
125/// Concatenate arrays from description sets (from computil.c cd_arrcat)
126pub fn cd_arrcat(sets: &[CompDescSet]) -> Vec<String> {
127    sets.iter()
128        .flat_map(|s| s.items.iter().map(|i| i.word.clone()))
129        .collect()
130}
131
132/// Duplicate description set arrays (from computil.c cd_arrdup)
133pub fn cd_arrdup(set: &CompDescSet) -> CompDescSet {
134    set.clone()
135}
136
137/// Free description sets (from computil.c freecdsets) — no-op in Rust
138pub fn freecdsets(_sets: Vec<CompDescSet>) {}
139
140/// Group items by description (from computil.c cd_group)
141pub fn cd_group(items: &[CompDescItem]) -> HashMap<String, Vec<CompDescItem>> {
142    let mut groups: HashMap<String, Vec<CompDescItem>> = HashMap::new();
143    for item in items {
144        let key = if item.description.is_empty() {
145            "(no description)".to_string()
146        } else {
147            item.description.clone()
148        };
149        groups.entry(key).or_default().push(item.clone());
150    }
151    groups
152}
153
154/// Compare arrays for equality (from computil.c arrcmp)
155pub fn arrcmp(a: &[String], b: &[String]) -> bool {
156    a == b
157}
158
159// --- _arguments support (from computil.c parse_caarg / alloc_cadef / set_cadef_opts) ---
160
161/// Completion argument definition (from computil.c Caarg)
162#[derive(Debug, Clone)]
163pub struct CompArgDef {
164    pub num: i32,       // Argument position (1-based, -1 for rest)
165    pub action: String, // Action to take
166    pub description: String,
167    pub optional: bool,
168    pub repeated: bool,
169}
170
171/// Completion option definition (from computil.c Caopt)
172#[derive(Debug, Clone)]
173pub struct CompOptDef {
174    pub name: String, // Option name (e.g., "-v", "--verbose")
175    pub description: String,
176    pub has_arg: bool,          // Whether option takes an argument
177    pub arg_desc: String,       // Argument description
178    pub exclusive: Vec<String>, // Mutually exclusive options
179}
180
181/// Full completion definition for a command (from computil.c Cadef)
182#[derive(Debug, Clone, Default)]
183pub struct CompCommandDef {
184    pub options: Vec<CompOptDef>,
185    pub arguments: Vec<CompArgDef>,
186    pub subcommands: HashMap<String, CompCommandDef>,
187}
188
189/// Parse a _arguments spec string (from computil.c parse_caarg)
190pub fn parse_caarg(spec: &str) -> Option<CompArgDef> {
191    // Format: "N:description:action" or "*:description:action"
192    let parts: Vec<&str> = spec.splitn(3, ':').collect();
193    if parts.is_empty() {
194        return None;
195    }
196
197    let (num, optional) = if parts[0] == "*" {
198        (-1, false)
199    } else if parts[0].starts_with('?') {
200        (parts[0][1..].parse().unwrap_or(0), true)
201    } else {
202        (parts[0].parse().unwrap_or(0), false)
203    };
204
205    Some(CompArgDef {
206        num,
207        description: parts.get(1).unwrap_or(&"").to_string(),
208        action: parts.get(2).unwrap_or(&"").to_string(),
209        optional,
210        repeated: parts[0] == "*",
211    })
212}
213
214/// Parse an option spec (from computil.c set_cadef_opts)
215pub fn parse_caopt(spec: &str) -> Option<CompOptDef> {
216    // Format: "-o[description]" or "--option[description]:arg_desc:action"
217    // or "(-a -b)-c[description]"
218
219    let spec = spec.trim();
220    if spec.is_empty() {
221        return None;
222    }
223
224    // Extract exclusions
225    let (exclusive, rest) = if spec.starts_with('(') {
226        if let Some(close) = spec.find(')') {
227            let excl: Vec<String> = spec[1..close]
228                .split_whitespace()
229                .map(String::from)
230                .collect();
231            (excl, spec[close + 1..].trim())
232        } else {
233            (Vec::new(), spec)
234        }
235    } else {
236        (Vec::new(), spec)
237    };
238
239    // Extract option name
240    let (name, after_name) = if rest.starts_with("--") {
241        let end = rest
242            .find('[')
243            .unwrap_or(rest.find(':').unwrap_or(rest.len()));
244        (&rest[..end], &rest[end..])
245    } else if rest.starts_with('-') {
246        let end = if rest.len() > 2 { 2 } else { rest.len() };
247        let end = rest[end..]
248            .find('[')
249            .map(|i| i + end)
250            .unwrap_or(rest[end..].find(':').map(|i| i + end).unwrap_or(rest.len()));
251        (&rest[..end], &rest[end..])
252    } else {
253        return None;
254    };
255
256    // Extract description from [...]
257    let description = if let Some(start) = after_name.find('[') {
258        if let Some(end) = after_name[start..].find(']') {
259            after_name[start + 1..start + end].to_string()
260        } else {
261            String::new()
262        }
263    } else {
264        String::new()
265    };
266
267    // Check for argument
268    let has_arg = after_name.contains(':');
269    let arg_desc = if has_arg {
270        after_name.rsplit(':').next().unwrap_or("").to_string()
271    } else {
272        String::new()
273    };
274
275    Some(CompOptDef {
276        name: name.to_string(),
277        description,
278        has_arg,
279        arg_desc,
280        exclusive,
281    })
282}
283
284/// Remove backslash-escaped colons (from computil.c rembslashcolon)
285pub fn rembslashcolon(s: &str) -> String {
286    s.replace("\\:", ":")
287}
288
289/// Add backslash before colons (from computil.c bslashcolon)
290pub fn bslashcolon(s: &str) -> String {
291    s.replace(':', "\\:")
292}
293
294/// Single index lookup (from computil.c single_index)
295pub fn single_index(arr: &[String], val: &str) -> Option<usize> {
296    arr.iter().position(|s| s == val)
297}
298
299/// Free completion argument definitions (from computil.c freecaargs/freecadef) — no-op
300pub fn freecaargs(_args: Vec<CompArgDef>) {}
301pub fn freecadef(_def: CompCommandDef) {}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_cd_get() {
309        let item = cd_get("commit:Record changes");
310        assert_eq!(item.word, "commit");
311        assert_eq!(item.description, "Record changes");
312
313        let item = cd_get("plain");
314        assert_eq!(item.word, "plain");
315        assert_eq!(item.description, "");
316    }
317
318    #[test]
319    fn test_cd_init() {
320        let specs = vec!["a:first".into(), "b:second".into(), "c:third".into()];
321        let set = cd_init(&specs, "options", "group1");
322        assert_eq!(set.items.len(), 3);
323        assert_eq!(set.tag, "options");
324    }
325
326    #[test]
327    fn test_cd_sort() {
328        let mut set = cd_init(
329            &vec!["c:third".into(), "a:first".into(), "b:second".into()],
330            "",
331            "",
332        );
333        cd_sort(&mut set);
334        assert_eq!(set.items[0].word, "a");
335        assert_eq!(set.items[2].word, "c");
336    }
337
338    #[test]
339    fn test_cd_prep() {
340        let items = vec![
341            CompDescItem {
342                word: "short".into(),
343                description: "A short one".into(),
344                hidden: false,
345            },
346            CompDescItem {
347                word: "longer".into(),
348                description: "A longer one".into(),
349                hidden: false,
350            },
351        ];
352        let formatted = cd_prep(&items, " -- ");
353        assert!(formatted[0].contains(" -- "));
354        assert!(formatted[1].contains(" -- "));
355    }
356
357    #[test]
358    fn test_parse_caarg() {
359        let arg = parse_caarg("1:file:_files").unwrap();
360        assert_eq!(arg.num, 1);
361        assert_eq!(arg.description, "file");
362        assert_eq!(arg.action, "_files");
363
364        let arg = parse_caarg("*:rest args:_files").unwrap();
365        assert_eq!(arg.num, -1);
366        assert!(arg.repeated);
367    }
368
369    #[test]
370    fn test_parse_caopt() {
371        let opt = parse_caopt("-v[verbose output]").unwrap();
372        assert_eq!(opt.name, "-v");
373        assert_eq!(opt.description, "verbose output");
374        assert!(!opt.has_arg);
375
376        let opt = parse_caopt("--output[output file]:file:_files").unwrap();
377        assert_eq!(opt.name, "--output");
378        assert!(opt.has_arg);
379    }
380
381    #[test]
382    fn test_rembslashcolon() {
383        assert_eq!(rembslashcolon("a\\:b\\:c"), "a:b:c");
384    }
385
386    #[test]
387    fn test_bslashcolon() {
388        assert_eq!(bslashcolon("a:b:c"), "a\\:b\\:c");
389    }
390
391    #[test]
392    fn test_cd_group() {
393        let items = vec![
394            CompDescItem {
395                word: "a".into(),
396                description: "group1".into(),
397                hidden: false,
398            },
399            CompDescItem {
400                word: "b".into(),
401                description: "group1".into(),
402                hidden: false,
403            },
404            CompDescItem {
405                word: "c".into(),
406                description: "group2".into(),
407                hidden: false,
408            },
409        ];
410        let groups = cd_group(&items);
411        assert_eq!(groups.len(), 2);
412        assert_eq!(groups["group1"].len(), 2);
413    }
414}