Skip to main content

majordomus_cli/command_graph/
complete.rs

1//! One completion engine, for every surface.
2//!
3//! A shell adapter parses the line it is completing and renders what comes back. It knows
4//! no commands, no flags, no identifiers and no repository: it forwards a
5//! [`Request`] and prints a [`Response`]. Everything else happens here, over the command
6//! graph, which is why `just <TAB>`, `majordomus <TAB>` and `./bin/majordomus <TAB>` cannot
7//! disagree about what exists — they are three spellings resolved against one graph by one
8//! function.
9//!
10//! ```text
11//!   zsh / bash / fish
12//!         │  words, cursor
13//!         ▼
14//!   generic adapter ──► majordomus completion query ──► CompletionEngine
15//!                                                            │
16//!                                              CommandGraph  +  value sources
17//! ```
18//!
19//! # What it will not do
20//!
21//! - **No network.** A value that can only be learned remotely is not offered.
22//! - **No secret.** An argument classified as a secret is never enumerated, and its value
23//!   never enters a candidate, a cache or a log.
24//! - **No dependence on a server.** A running server may make a value source faster; a
25//!   stopped one changes nothing. Nothing here starts one.
26
27use std::path::PathBuf;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use super::model::{ArgumentSpec, CommandGraph, CommandNode, Origin, Surface, ValueSource};
33use super::policy;
34
35/// What a shell asks.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37pub struct Request {
38    /// Which spelling the words are in.
39    pub surface: Surface,
40    /// The words of the command line, the program's own name included at index 0.
41    pub words: Vec<String>,
42    /// The index of the word the cursor is in. A cursor past the last word is a new,
43    /// empty word.
44    pub cursor: usize,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    /// Where the shell is. Used only to make a path candidate relative; never scanned.
47    pub cwd: Option<PathBuf>,
48}
49
50impl Request {
51    /// The text of the word being completed, empty when the cursor opens a new one.
52    pub fn prefix(&self) -> &str {
53        self.words
54            .get(self.cursor)
55            .map(String::as_str)
56            .unwrap_or("")
57    }
58
59    /// The words before the cursor, the program's own name excluded.
60    pub fn preceding(&self) -> &[String] {
61        let end = self.cursor.min(self.words.len());
62        let start = 1.min(end);
63        &self.words[start..end]
64    }
65}
66
67/// What kind of thing a candidate is, so a shell that groups its menu can.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "snake_case")]
70pub enum CandidateKind {
71    /// A command or a subcommand.
72    Command,
73    /// A recipe of the workflow runner.
74    Workflow,
75    /// An option, with its dashes.
76    Flag,
77    /// A value of an argument.
78    Value,
79    /// A path; the shell completes the rest itself.
80    Path,
81}
82
83/// One thing the caller may type next.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85pub struct Candidate {
86    /// What to insert.
87    pub value: String,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    /// One line about it, for a shell that shows descriptions.
90    pub description: Option<String>,
91    /// What kind of thing it is.
92    pub kind: CandidateKind,
93    /// Whether a space belongs after it.
94    pub append_space: bool,
95    /// Lower sorts first. Derived from what the candidate is, never from a preference.
96    pub priority: i32,
97}
98
99/// What the engine answers.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101pub struct Response {
102    /// The candidates, ordered.
103    pub candidates: Vec<Candidate>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    /// The command the words resolved to, for a debugging adapter.
106    pub resolved: Option<String>,
107    /// Whether the shell should fall back to its own file completion as well.
108    pub files: bool,
109}
110
111/// Where a value that is not in the declaration comes from.
112///
113/// The graph knows that `<BRANCH>` is a branch; it does not know which branches exist. A
114/// resolver answers that, and the same resolver answers it for a shell, for a generated
115/// form and for a machine surface — so a select in the Cockpit and a TAB in a terminal
116/// cannot offer different sets.
117pub trait ValueResolver {
118    /// The values of one source, or none when this resolver cannot answer it here.
119    fn values(&self, source: ValueSource) -> Vec<String>;
120}
121
122/// A resolver that answers nothing.
123///
124/// The engine is complete without one: command and flag completion, and every value the
125/// declaration carries, are answered from the graph alone. This is what the fast path uses
126/// and what a checkout with no repository gets.
127pub struct NoValues;
128
129impl ValueResolver for NoValues {
130    fn values(&self, _source: ValueSource) -> Vec<String> {
131        Vec::new()
132    }
133}
134
135/// Complete one line.
136pub fn complete(graph: &CommandGraph, request: &Request, values: &dyn ValueResolver) -> Response {
137    let prefix = request.prefix().to_string();
138    let words = request.preceding();
139
140    match request.surface {
141        Surface::Workflow => complete_workflow(graph, &prefix, words, values),
142        _ => complete_cli(graph, &prefix, words, values),
143    }
144}
145
146/// The command line of either program: `majordomus <TAB>`.
147fn complete_cli(
148    graph: &CommandGraph,
149    prefix: &str,
150    words: &[String],
151    values: &dyn ValueResolver,
152) -> Response {
153    // The longest command path the words spell. Both programs answer to `majordomus`, so
154    // both are searched and the deeper match wins; a word that names neither ends the walk
155    // and everything after it is an argument.
156    let mut path: Vec<String> = Vec::new();
157    let mut resolved: Option<&CommandNode> = None;
158    let mut consumed = 0usize;
159    for (i, word) in words.iter().enumerate() {
160        if word.starts_with('-') {
161            break;
162        }
163        let mut candidate_path = path.clone();
164        candidate_path.push(word.clone());
165        match graph.commands.iter().find(|c| {
166            matches!(c.origin, Origin::Executable | Origin::Tool) && c.path == candidate_path
167        }) {
168            Some(node) => {
169                path = candidate_path;
170                resolved = Some(node);
171                consumed = i + 1;
172            }
173            None => break,
174        }
175    }
176    let rest = &words[consumed.min(words.len())..];
177    candidates_for(graph, resolved, &path, rest, prefix, values, false)
178}
179
180/// The workflow runner: `just <TAB>`.
181fn complete_workflow(
182    graph: &CommandGraph,
183    prefix: &str,
184    words: &[String],
185    values: &dyn ValueResolver,
186) -> Response {
187    // The runner takes one recipe name and then arguments. Resolving that name back to the
188    // canonical node is the whole of the surface alias handling: what follows is completed
189    // by the same code that completes the command line, from the same argument metadata.
190    let Some(name) = words.first() else {
191        let mut candidates: Vec<Candidate> = graph
192            .commands
193            .iter()
194            .filter(|c| policy::offered_on(c, Surface::Workflow))
195            .filter_map(|c| {
196                let name = c.projections.workflow.clone()?;
197                Some(Candidate {
198                    description: Some(summary(c)),
199                    kind: CandidateKind::Workflow,
200                    append_space: true,
201                    priority: priority_of(c),
202                    value: name,
203                })
204            })
205            .collect();
206        return finish(&mut candidates, prefix, None, false);
207    };
208    let Some(node) = graph.resolve(Surface::Workflow, name) else {
209        return Response {
210            candidates: Vec::new(),
211            resolved: None,
212            files: true,
213        };
214    };
215    let path = node.path.clone();
216    candidates_for(graph, Some(node), &path, &words[1..], prefix, values, true)
217}
218
219/// The candidates at one point of one command.
220#[allow(clippy::too_many_arguments)]
221fn candidates_for<'a>(
222    graph: &'a CommandGraph,
223    node: Option<&'a CommandNode>,
224    path: &[String],
225    rest: &[String],
226    prefix: &str,
227    values: &dyn ValueResolver,
228    in_workflow: bool,
229) -> Response {
230    let mut out: Vec<Candidate> = Vec::new();
231    let mut files = false;
232
233    // A flag that takes a value, immediately before the cursor, decides everything.
234    if let Some(previous) = rest.last() {
235        if previous.starts_with('-') {
236            if let Some(arg) = node.and_then(|n| flag(n, previous)) {
237                if arg.takes_value {
238                    let (mut vals, wants_files) = value_candidates(arg, values);
239                    return finish(&mut vals, prefix, node, wants_files);
240                }
241            }
242        }
243    }
244
245    if prefix.starts_with('-') {
246        if let Some(node) = node {
247            for arg in &node.arguments {
248                if arg.positional {
249                    continue;
250                }
251                if let Some(long) = &arg.long {
252                    out.push(Candidate {
253                        value: format!("--{long}"),
254                        description: Some(arg.help.clone()),
255                        kind: CandidateKind::Flag,
256                        append_space: !arg.takes_value,
257                        priority: if arg.required { 10 } else { 20 },
258                    });
259                }
260            }
261        }
262        return finish(&mut out, prefix, node, false);
263    }
264
265    // The subcommands under the resolved point, unless the surface has no notion of one:
266    // the workflow runner takes a recipe and then arguments, never a subcommand.
267    if !in_workflow {
268        for child in graph.commands.iter().filter(|c| {
269            matches!(c.origin, Origin::Executable | Origin::Tool)
270                && c.path.len() == path.len() + 1
271                && c.path.starts_with(path)
272        }) {
273            out.push(Candidate {
274                value: child.path.last().cloned().unwrap_or_default(),
275                description: Some(summary(child)),
276                kind: CandidateKind::Command,
277                append_space: true,
278                priority: priority_of(child),
279            });
280        }
281        if let Some(node) = node {
282            for alias in &node.aliases {
283                out.push(Candidate {
284                    value: alias.clone(),
285                    description: Some(format!("alias of {}", node.invocation)),
286                    kind: CandidateKind::Command,
287                    append_space: true,
288                    priority: 60,
289                });
290            }
291        }
292    }
293
294    // The next positional argument's values, when one is expected.
295    if let Some(node) = node {
296        let given = rest.iter().filter(|w| !w.starts_with('-')).count();
297        if let Some(arg) = node.arguments.iter().filter(|a| a.positional).nth(given) {
298            let (mut vals, wants_files) = value_candidates(arg, values);
299            files |= wants_files;
300            out.append(&mut vals);
301        }
302    }
303
304    finish(&mut out, prefix, node, files)
305}
306
307/// The candidates for one argument's value, and whether the shell should also offer files.
308fn value_candidates(arg: &ArgumentSpec, values: &dyn ValueResolver) -> (Vec<Candidate>, bool) {
309    // A secret is never enumerated. This is checked here as well as in the graph's own
310    // validation, because a completion is the one place where offering a value is the same
311    // as printing it.
312    if !arg.source.suggestible() {
313        return (Vec::new(), matches!(arg.source, ValueSource::Free));
314    }
315    match arg.source {
316        ValueSource::Enumerated => (
317            arg.values
318                .iter()
319                .map(|v| Candidate {
320                    value: v.value.clone(),
321                    description: v.description.clone(),
322                    kind: CandidateKind::Value,
323                    append_space: true,
324                    priority: 30,
325                })
326                .collect(),
327            false,
328        ),
329        ValueSource::Path | ValueSource::RepositoryPath => (Vec::new(), true),
330        other => (
331            values
332                .values(other)
333                .into_iter()
334                .map(|v| Candidate {
335                    value: v,
336                    description: None,
337                    kind: CandidateKind::Value,
338                    append_space: true,
339                    priority: 30,
340                })
341                .collect(),
342            false,
343        ),
344    }
345}
346
347/// The flag one word names, long or short.
348fn flag<'a>(node: &'a CommandNode, word: &str) -> Option<&'a ArgumentSpec> {
349    let bare = word.trim_start_matches('-');
350    node.arguments.iter().find(|a| {
351        a.long.as_deref() == Some(bare) || a.short.map(|c| c.to_string()).as_deref() == Some(bare)
352    })
353}
354
355/// One line about a command, with the reason it is unavailable when it is.
356fn summary(node: &CommandNode) -> String {
357    match (&node.availability.available, &node.availability.reason) {
358        (false, Some(reason)) => format!("{} (unavailable: {reason})", node.summary),
359        _ => node.summary.clone(),
360    }
361}
362
363/// Where a command sorts among its siblings.
364///
365/// Deterministic and derived: an entry point first, then by what running it changes, then
366/// alphabetically by the value itself in [`finish`]. Nothing reorders by use, because a
367/// completion whose order depends on history is a completion nobody can predict.
368fn priority_of(node: &CommandNode) -> i32 {
369    let mut p = match node.origin {
370        Origin::Workflow => 0,
371        Origin::Tool => 10,
372        Origin::Executable => 10,
373    };
374    if node.entrypoint == Some(true) {
375        p -= 5;
376    }
377    if !node.availability.available {
378        p += 100;
379    }
380    p + (node.effect as i32)
381}
382
383/// Filter by the prefix, order deterministically, answer.
384fn finish(
385    candidates: &mut Vec<Candidate>,
386    prefix: &str,
387    node: Option<&CommandNode>,
388    files: bool,
389) -> Response {
390    candidates.retain(|c| c.value.starts_with(prefix));
391    candidates.sort_by(|a, b| (a.priority, &a.value).cmp(&(b.priority, &b.value)));
392    candidates.dedup_by(|a, b| a.value == b.value);
393    Response {
394        candidates: std::mem::take(candidates),
395        resolved: node.map(|n| n.id.to_string()),
396        files,
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::command_graph::{build, Inputs};
404
405    fn words(line: &str) -> Vec<String> {
406        line.split_whitespace().map(String::from).collect()
407    }
408
409    fn request(surface: Surface, line: &str, trailing_space: bool) -> Request {
410        let words = words(line);
411        let cursor = if trailing_space {
412            words.len()
413        } else {
414            words.len().saturating_sub(1)
415        };
416        Request {
417            surface,
418            words,
419            cursor,
420            cwd: None,
421        }
422    }
423
424    #[test]
425    fn the_root_offers_the_top_level_commands() {
426        let graph = build(&Inputs::default());
427        let r = complete(
428            &graph,
429            &request(Surface::Cli, "majordomus", true),
430            &NoValues,
431        );
432        let names: Vec<&str> = r.candidates.iter().map(|c| c.value.as_str()).collect();
433        assert!(names.contains(&"worktree"), "{names:?}");
434        assert!(names.contains(&"serve"), "{names:?}");
435    }
436
437    #[test]
438    fn a_prefix_narrows_it() {
439        let graph = build(&Inputs::default());
440        let r = complete(
441            &graph,
442            &request(Surface::Cli, "majordomus work", false),
443            &NoValues,
444        );
445        assert!(r.candidates.iter().all(|c| c.value.starts_with("work")));
446        assert!(r.candidates.iter().any(|c| c.value == "worktree"));
447    }
448
449    #[test]
450    fn a_nested_command_offers_its_subcommands() {
451        let graph = build(&Inputs::default());
452        let r = complete(
453            &graph,
454            &request(Surface::Cli, "majordomus worktree", true),
455            &NoValues,
456        );
457        let names: Vec<&str> = r.candidates.iter().map(|c| c.value.as_str()).collect();
458        assert!(names.contains(&"status"), "{names:?}");
459        assert!(names.contains(&"migrate"), "{names:?}");
460        assert_eq!(r.resolved.as_deref(), Some("executable.worktree"));
461    }
462
463    #[test]
464    fn a_dash_offers_the_flags_of_the_resolved_command() {
465        let graph = build(&Inputs::default());
466        let r = complete(
467            &graph,
468            &request(Surface::Cli, "majordomus worktree status -", false),
469            &NoValues,
470        );
471        assert!(r.candidates.iter().all(|c| c.kind == CandidateKind::Flag));
472        assert!(r.candidates.iter().any(|c| c.value == "--repo"));
473    }
474
475    #[test]
476    fn a_value_enum_completes_from_the_declaration() {
477        let graph = build(&Inputs::default());
478        // `--format` is a value enum on several commands; whichever carries it, the values
479        // come from clap and not from a list here.
480        let node = graph
481            .commands
482            .iter()
483            .find(|n| n.arguments.iter().any(|a| !a.values.is_empty()))
484            .expect("some command has a value enum");
485        let arg = node
486            .arguments
487            .iter()
488            .find(|a| !a.values.is_empty())
489            .unwrap();
490        let long = arg.long.clone().expect("a value enum is an option here");
491        let line = format!("majordomus {} --{long}", node.path.join(" "));
492        let r = complete(&graph, &request(Surface::Cli, &line, true), &NoValues);
493        assert!(!r.candidates.is_empty(), "no values for --{long}");
494        assert!(r.candidates.iter().all(|c| c.kind == CandidateKind::Value));
495    }
496
497    #[test]
498    fn the_workflow_surface_offers_recipes_and_then_the_same_arguments() {
499        let graph = build(&Inputs::default());
500        let r = complete(&graph, &request(Surface::Workflow, "just", true), &NoValues);
501        assert!(r
502            .candidates
503            .iter()
504            .any(|c| c.value == "worktree-status" && c.kind == CandidateKind::Workflow));
505
506        let r = complete(
507            &graph,
508            &request(Surface::Workflow, "just worktree-status -", false),
509            &NoValues,
510        );
511        // The same flags the command line offers, from the same node.
512        assert!(r.candidates.iter().any(|c| c.value == "--repo"));
513        assert_eq!(r.resolved.as_deref(), Some("executable.worktree.status"));
514    }
515
516    #[test]
517    fn a_dynamic_source_comes_from_the_resolver_and_never_from_the_engine() {
518        struct Branches;
519        impl ValueResolver for Branches {
520            fn values(&self, source: ValueSource) -> Vec<String> {
521                match source {
522                    ValueSource::Branch => vec!["master".into(), "feature/x".into()],
523                    _ => Vec::new(),
524                }
525            }
526        }
527        let graph = build(&Inputs::default());
528        let r = complete(
529            &graph,
530            &request(Surface::Cli, "majordomus worktree path", true),
531            &Branches,
532        );
533        let names: Vec<&str> = r.candidates.iter().map(|c| c.value.as_str()).collect();
534        assert!(names.contains(&"master"), "{names:?}");
535        // and with no resolver, nothing is invented
536        let r = complete(
537            &graph,
538            &request(Surface::Cli, "majordomus worktree path", true),
539            &NoValues,
540        );
541        assert!(r.candidates.iter().all(|c| c.kind != CandidateKind::Value));
542    }
543
544    #[test]
545    fn an_unknown_command_does_not_panic() {
546        let graph = build(&Inputs::default());
547        for line in [
548            "majordomus nonesuch --unknown",
549            "majordomus worktree nonesuch",
550            "just nonesuch --x",
551            "majordomus",
552        ] {
553            let _ = complete(&graph, &request(Surface::Cli, line, true), &NoValues);
554            let _ = complete(&graph, &request(Surface::Workflow, line, false), &NoValues);
555        }
556    }
557}