Skip to main content

majordomus_cli/command_graph/
shell.rs

1//! The shell adapters: the only shell code in the completion, and it knows nothing.
2//!
3//! Each adapter below does four things — read the words being completed, find the cursor,
4//! ask this executable, print what comes back. There is no command in them, no flag, no
5//! identifier, no repository path and no version. That is the property that matters: a
6//! command added tomorrow is completed by an adapter loaded a year ago, because the
7//! adapter never learned the old set in the first place.
8//!
9//! # What a person installs, once
10//!
11//! ```sh
12//! # ~/.zshrc
13//! eval "$(majordomus completion init --shell zsh)"
14//! ```
15//!
16//! One line, for every repository. The repository being completed is decided per
17//! invocation by where the shell is, which is how one integration serves many checkouts
18//! and how leaving a repository leaves nothing behind.
19//!
20//! # The text protocol
21//!
22//! One candidate per line, `value`, a tab, the description. Empty output means the engine
23//! has nothing to offer, and the adapter falls back to the shell's own file completion —
24//! which is what an argument that takes a path wants, and what an unknown command deserves.
25
26/// The environment variable an adapter reads to find the executable, so that a checkout's
27/// own launcher can be used without a second adapter.
28pub const BIN_ENV: &str = "MAJORDOMUS_COMPLETION_BIN";
29
30/// The zsh integration.
31///
32/// `compdef` is attached to the two spellings of this executable, to the shell tool, and to
33/// the workflow runner. The runner's completion is the same function with a different
34/// surface, which is the whole of the "no second completion implementation" property: one
35/// query, one engine, one graph.
36pub const ZSH: &str = r#"# majordomus — shell completion, generated by `majordomus completion init --shell zsh`.
37# Carries no command, no flag and no identifier: every candidate comes from
38# `majordomus completion query`, which answers from the command graph of the repository the
39# shell is in. Load it once; it serves every repository.
40_majordomus_query() {
41  local surface="$1"
42  local bin="${MAJORDOMUS_COMPLETION_BIN:-majordomus}"
43  local -a lines items
44  lines=("${(@f)$(command "$bin" completion query --surface "$surface" --format text --cursor "$((CURRENT-1))" -- "${words[@]}" 2>/dev/null)}")
45  local line value description
46  for line in "${lines[@]}"; do
47    [[ -z "$line" ]] && continue
48    value="${line%%	*}"
49    description="${line#*	}"
50    [[ "$description" == "$line" ]] && description=""
51    items+=("${value}:${description}")
52  done
53  if (( ${#items} )); then
54    _describe -t majordomus 'candidate' items
55  else
56    _files
57  fi
58}
59_majordomus_complete() { _majordomus_query cli }
60_majordomus_workflow_complete() { _majordomus_query workflow }
61compdef _majordomus_complete majordomus
62compdef _majordomus_complete majordomus-cli
63compdef _majordomus_workflow_complete just
64"#;
65
66/// The bash integration.
67pub const BASH: &str = r#"# majordomus — shell completion, generated by `majordomus completion init --shell bash`.
68# Carries no command, no flag and no identifier: every candidate comes from
69# `majordomus completion query`.
70_majordomus_complete() {
71  local surface=cli
72  case "${COMP_WORDS[0]}" in just) surface=workflow ;; esac
73  local bin="${MAJORDOMUS_COMPLETION_BIN:-majordomus}"
74  local line value
75  COMPREPLY=()
76  while IFS= read -r line; do
77    [ -z "$line" ] && continue
78    value="${line%%	*}"
79    COMPREPLY+=("$value")
80  done < <(command "$bin" completion query --surface "$surface" --format text --cursor "$COMP_CWORD" -- "${COMP_WORDS[@]}" 2>/dev/null)
81  if [ ${#COMPREPLY[@]} -eq 0 ]; then
82    while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen -f -- "${COMP_WORDS[COMP_CWORD]}")
83  fi
84}
85complete -F _majordomus_complete majordomus
86complete -F _majordomus_complete majordomus-cli
87complete -F _majordomus_complete just
88"#;
89
90/// The fish integration.
91///
92/// fish's own completion protocol is a value, a tab, a description — which is the text
93/// protocol this engine already prints, so the adapter forwards the answer unaltered
94/// rather than reformatting it.
95pub const FISH: &str = r#"# majordomus — shell completion, generated by `majordomus completion init --shell fish`.
96# Carries no command, no flag and no identifier: every candidate comes from
97# `majordomus completion query`.
98function __majordomus_complete
99    set -l bin majordomus
100    if set -q MAJORDOMUS_COMPLETION_BIN
101        set bin $MAJORDOMUS_COMPLETION_BIN
102    end
103    set -l words (commandline -opc)
104    set -l current (commandline -ct)
105    set -l surface cli
106    if test (count $words) -gt 0; and test $words[1] = just
107        set surface workflow
108    end
109    command $bin completion query --surface $surface --format text --cursor (count $words) -- $words $current 2>/dev/null
110end
111complete -c majordomus -f -a '(__majordomus_complete)'
112complete -c majordomus-cli -f -a '(__majordomus_complete)'
113complete -c just -f -a '(__majordomus_complete)'
114"#;
115
116/// Every adapter, for a test that asserts the property they share.
117pub const ALL: &[(&str, &str)] = &[("zsh", ZSH), ("bash", BASH), ("fish", FISH)];
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::command_graph::{build, Inputs};
123
124    #[test]
125    fn no_adapter_carries_a_command_name() {
126        let graph = build(&Inputs::default());
127        for (shell, script) in ALL {
128            // The prose in an adapter explains what it is; the code is what could go
129            // stale. Only the code is read.
130            let code: String = script
131                .lines()
132                .filter(|l| !l.trim_start().starts_with('#'))
133                .collect::<Vec<_>>()
134                .join("\n");
135            // The one command an adapter is allowed to name is the query it makes; every
136            // other command word appearing here would be a set the adapter had learned.
137            let code = code.replace("completion query", "");
138            for node in &graph.commands {
139                if node.id.as_str().starts_with("executable.completion") {
140                    continue;
141                }
142                for word in node.path.iter().filter(|w| w.len() > 3) {
143                    assert!(
144                        !code.contains(&format!(" {word} ")),
145                        "the {shell} adapter names the command word `{word}`"
146                    );
147                }
148            }
149        }
150    }
151
152    #[test]
153    fn every_adapter_asks_the_same_question() {
154        for (shell, script) in ALL {
155            assert!(
156                script.contains("completion query"),
157                "the {shell} adapter does not ask"
158            );
159            assert!(
160                script.contains(BIN_ENV),
161                "the {shell} adapter cannot be pointed at a checkout's own launcher"
162            );
163            assert!(
164                script.contains("--surface"),
165                "the {shell} adapter does not say which surface it is completing"
166            );
167        }
168    }
169}