Skip to main content

rmux_core/
command_inventory.rs

1//! Shared command inventory and tmux-compatible `list-commands` rendering.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::sync::OnceLock;
6
7use crate::command_parser::{CommandEntry, COMMAND_TABLE};
8use crate::formats::{render_template, FormatContext};
9
10#[path = "command_inventory/signatures.rs"]
11mod signatures;
12
13use signatures::LIST_COMMAND_SIGNATURES;
14
15/// Typed short-option metadata used by internal command normalization.
16///
17/// Public options are derived from the frozen `list-commands` signature. A
18/// small internal supplement carries implemented tmux options that tmux itself
19/// intentionally omits from `list-commands`.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct CommandShortOptionSpec {
22    boolean_flags: String,
23    value_flags: String,
24}
25
26impl CommandShortOptionSpec {
27    fn from_usage(usage: &str) -> Self {
28        let mut spec = Self::default();
29        let mut remaining = usage;
30
31        while let Some(group_start) = remaining.find('[') {
32            let after_start = &remaining[group_start + 1..];
33            let Some(group_end) = after_start.find(']') else {
34                break;
35            };
36            spec.add_usage_group(&after_start[..group_end]);
37            remaining = &after_start[group_end + 1..];
38        }
39
40        spec
41    }
42
43    /// Returns whether `flag` is a boolean short option for the command.
44    #[must_use]
45    pub fn is_boolean(&self, flag: char) -> bool {
46        self.boolean_flags.contains(flag)
47    }
48
49    /// Returns whether `flag` consumes a value for the command.
50    #[must_use]
51    pub fn takes_value(&self, flag: char) -> bool {
52        self.value_flags.contains(flag)
53    }
54
55    fn add_boolean(&mut self, flag: char) {
56        if flag.is_ascii() && !self.value_flags.contains(flag) && !self.boolean_flags.contains(flag)
57        {
58            self.boolean_flags.push(flag);
59        }
60    }
61
62    fn add_value(&mut self, flag: char) {
63        if flag.is_ascii() && !self.value_flags.contains(flag) {
64            // tmux 3.7b advertises split-window's -e in both the compact
65            // boolean group and the explicit `-e environment` group.  The
66            // value-taking spelling is the only safe boundary for compact
67            // normalization, so an explicit value group wins.
68            self.boolean_flags.retain(|candidate| candidate != flag);
69            self.value_flags.push(flag);
70        }
71    }
72
73    fn add_usage_group(&mut self, group: &str) {
74        let group = group.trim();
75        if !group.starts_with('-') || group.starts_with("--") {
76            return;
77        }
78
79        let alternatives = group.split('|').collect::<Vec<_>>();
80        if alternatives.len() > 1
81            && alternatives
82                .iter()
83                .all(|alternative| single_short_flag(alternative.trim()).is_some())
84        {
85            for alternative in alternatives {
86                self.add_boolean(
87                    single_short_flag(alternative.trim())
88                        .expect("validated short-option alternative"),
89                );
90            }
91            return;
92        }
93
94        let mut words = group.split_ascii_whitespace();
95        let Some(option) = words.next() else {
96            return;
97        };
98        let Some(flags) = option.strip_prefix('-') else {
99            return;
100        };
101        if flags.is_empty() || flags.starts_with('-') {
102            return;
103        }
104
105        if words.next().is_some() {
106            if let Some(flag) = single_short_flag(option) {
107                self.add_value(flag);
108            }
109            return;
110        }
111
112        for flag in flags.chars() {
113            self.add_boolean(flag);
114        }
115    }
116
117    fn add_internal_boolean_flags(&mut self, flags: &str) {
118        for flag in flags.chars() {
119            self.add_boolean(flag);
120        }
121    }
122
123    fn add_internal_value_flags(&mut self, flags: &str) {
124        for flag in flags.chars() {
125            self.add_value(flag);
126        }
127    }
128}
129
130fn add_internal_short_options(command_name: &str, spec: &mut CommandShortOptionSpec) {
131    // tmux 3.7b accepts these flags but does not render them in
132    // `list-commands`. Keep normalization metadata separate so the public
133    // inventory remains oracle-exact.
134    match command_name {
135        "join-pane" | "move-pane" => spec.add_internal_value_flags("p"),
136        "list-buffers" => spec.add_internal_boolean_flags("r"),
137        "load-buffer" => spec.add_internal_boolean_flags("w"),
138        _ => {}
139    }
140}
141
142/// Returns typed short-option metadata for a public command.
143///
144/// Metadata is derived from the same frozen signature rendered by
145/// `list-commands`, including adjacent option groups and tmux's `-L|-S|-U`
146/// spelling for mutually exclusive boolean flags.
147#[must_use]
148pub fn command_short_option_spec(command_name: &str) -> Option<&'static CommandShortOptionSpec> {
149    static SPECS: OnceLock<Vec<(&'static str, CommandShortOptionSpec)>> = OnceLock::new();
150    SPECS
151        .get_or_init(|| {
152            LIST_COMMAND_SIGNATURES
153                .iter()
154                .map(|(name, usage)| {
155                    let mut spec = CommandShortOptionSpec::from_usage(usage);
156                    add_internal_short_options(name, &mut spec);
157                    (*name, spec)
158                })
159                .collect()
160        })
161        .iter()
162        .find_map(|(name, spec)| (*name == command_name).then_some(spec))
163}
164
165fn single_short_flag(option: &str) -> Option<char> {
166    let mut flags = option.strip_prefix('-')?.chars();
167    let flag = flags.next()?;
168    (flags.next().is_none() && flag.is_ascii()).then_some(flag)
169}
170
171/// Exact-only RMUX command extensions shared by client parsing and internal
172/// server canonicalization. They never participate in tmux prefix matching.
173pub const RMUX_EXTENSION_COMMANDS: &[CommandEntry] = &[
174    CommandEntry {
175        name: "capabilities",
176        alias: None,
177    },
178    CommandEntry {
179        name: "claude",
180        alias: None,
181    },
182    CommandEntry {
183        name: "doctor",
184        alias: None,
185    },
186    CommandEntry {
187        name: "setup",
188        alias: None,
189    },
190    CommandEntry {
191        name: "wait-pane",
192        alias: None,
193    },
194    CommandEntry {
195        name: "pane-snapshot",
196        alias: None,
197    },
198    CommandEntry {
199        name: "stream-pane",
200        alias: None,
201    },
202    CommandEntry {
203        name: "collect-pane-output",
204        alias: None,
205    },
206    CommandEntry {
207        name: "locator",
208        alias: None,
209    },
210    CommandEntry {
211        name: "expect-pane",
212        alias: None,
213    },
214    CommandEntry {
215        name: "find-panes",
216        alias: None,
217    },
218    CommandEntry {
219        name: "find-sessions",
220        alias: None,
221    },
222    CommandEntry {
223        name: "broadcast-keys",
224        alias: None,
225    },
226    CommandEntry {
227        name: "with-session",
228        alias: None,
229    },
230    CommandEntry {
231        name: "web-share",
232        alias: None,
233    },
234];
235
236/// A typed command-name resolution failure from `list-commands`.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum ListCommandsError {
239    /// No public command or alias matched the requested name.
240    Unknown(String),
241    /// More than one public tmux command matched the requested prefix.
242    Ambiguous(String),
243}
244
245impl fmt::Display for ListCommandsError {
246    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247        match self {
248            Self::Unknown(name) => write!(formatter, "unknown command: {name}"),
249            Self::Ambiguous(name) => write!(formatter, "ambiguous command: {name}"),
250        }
251    }
252}
253
254impl std::error::Error for ListCommandsError {}
255
256/// Renders the selected command inventory using tmux `list-commands` rules.
257///
258/// A bare listing omits RMUX-only extensions. An explicit lookup may select an
259/// extension by its exact name, matching RMUX's direct CLI behavior.
260pub fn render_list_commands(
261    format: Option<&str>,
262    requested_command: Option<&str>,
263) -> Result<Vec<String>, ListCommandsError> {
264    render_list_commands_with_optional_socket_path(format, requested_command, None)
265}
266
267/// Renders the selected command inventory with the selected server socket available to formats.
268pub fn render_list_commands_for_socket(
269    format: Option<&str>,
270    requested_command: Option<&str>,
271    socket_path: &str,
272) -> Result<Vec<String>, ListCommandsError> {
273    render_list_commands_with_optional_socket_path(format, requested_command, Some(socket_path))
274}
275
276fn render_list_commands_with_optional_socket_path(
277    format: Option<&str>,
278    requested_command: Option<&str>,
279    socket_path: Option<&str>,
280) -> Result<Vec<String>, ListCommandsError> {
281    let requested = requested_command
282        .map(resolve_list_commands_target)
283        .transpose()?;
284    Ok(LIST_COMMAND_SIGNATURES
285        .iter()
286        .copied()
287        .filter(|(name, _)| match requested {
288            None => !is_rmux_extension(name),
289            Some(requested_name) => *name == requested_name,
290        })
291        .filter_map(|(name, _)| {
292            let line = render_list_commands_line_with_optional_socket_path(
293                format,
294                name,
295                list_command_alias(name),
296                socket_path,
297            );
298            if format.is_some() && line.is_empty() {
299                None
300            } else {
301                Some(line)
302            }
303        })
304        .collect())
305}
306
307/// Renders one command inventory line with tmux command-list format fields.
308#[must_use]
309pub fn render_list_commands_line(format: Option<&str>, name: &str, alias: Option<&str>) -> String {
310    render_list_commands_line_with_optional_socket_path(format, name, alias, None)
311}
312
313fn render_list_commands_line_with_optional_socket_path(
314    format: Option<&str>,
315    name: &str,
316    alias: Option<&str>,
317    socket_path: Option<&str>,
318) -> String {
319    let alias = alias.unwrap_or("");
320    match format {
321        Some(template) => {
322            let usage = list_command_usage_without_alias(name);
323            render_list_commands_template(template, name, alias, usage.as_ref(), socket_path)
324        }
325        None => format!("{name} {}", list_command_usage(name)),
326    }
327}
328
329/// Iterates over every command name in inventory order, including RMUX extensions.
330pub fn list_command_names() -> impl Iterator<Item = &'static str> {
331    LIST_COMMAND_SIGNATURES.iter().map(|(name, _)| *name)
332}
333
334/// Returns whether a tmux command name or exact alias can resolve through the
335/// frozen command table, including ambiguous command-name prefixes.
336///
337/// This is intentionally broader than [`crate::command_parser::lookup_command`]:
338/// callers that need to preserve tmux's ambiguity diagnostic must still send
339/// ambiguous prefixes through the command parser rather than treating them as
340/// unknown extension aliases.
341#[must_use]
342pub fn has_tmux_command_candidate(name: &str) -> bool {
343    COMMAND_TABLE
344        .iter()
345        .any(|entry| entry.alias == Some(name) || entry.name.starts_with(name))
346}
347
348fn resolve_list_commands_target(name: &str) -> Result<&'static str, ListCommandsError> {
349    let name = list_commands_parser_alias(name);
350    if let Some((command_name, _)) = LIST_COMMAND_SIGNATURES.iter().find(|(command_name, _)| {
351        *command_name == name || list_command_alias(command_name) == Some(name)
352    }) {
353        return Ok(command_name);
354    }
355
356    let matches = LIST_COMMAND_SIGNATURES
357        .iter()
358        .map(|(command_name, _)| *command_name)
359        .filter(|command_name| !is_rmux_extension(command_name))
360        .filter(|command_name| {
361            command_name.starts_with(name)
362                || list_command_alias(command_name).is_some_and(|alias| alias.starts_with(name))
363        })
364        .collect::<Vec<_>>();
365
366    match matches.as_slice() {
367        [command] => Ok(command),
368        [] => Err(ListCommandsError::Unknown(name.to_owned())),
369        _ => Err(ListCommandsError::Ambiguous(name.to_owned())),
370    }
371}
372
373fn list_commands_parser_alias(name: &str) -> &str {
374    match name {
375        "choose-session" | "choose-window" => "choose-tree",
376        _ => name,
377    }
378}
379
380fn list_command_alias(name: &str) -> Option<&'static str> {
381    COMMAND_TABLE
382        .iter()
383        .find(|entry| entry.name == name)
384        .and_then(|entry| entry.alias)
385}
386
387fn is_rmux_extension(name: &str) -> bool {
388    RMUX_EXTENSION_COMMANDS
389        .iter()
390        .any(|entry| entry.name == name)
391}
392
393fn render_list_commands_template(
394    template: &str,
395    name: &str,
396    alias: &str,
397    usage: &str,
398    socket_path: Option<&str>,
399) -> String {
400    let mut variables = FormatContext::new()
401        .with_named_value("command_list_name", name)
402        .with_named_value("command_list_alias", alias)
403        .with_named_value("command_list_usage", usage);
404    if let Some(socket_path) = socket_path {
405        variables = variables.with_named_value("socket_path", socket_path);
406    }
407    render_template(template, &variables)
408}
409
410fn list_command_usage(name: &str) -> &'static str {
411    LIST_COMMAND_SIGNATURES
412        .iter()
413        .find_map(|(command_name, usage)| (*command_name == name).then_some(*usage))
414        .unwrap_or("")
415}
416
417fn list_command_usage_without_alias(name: &str) -> Cow<'static, str> {
418    let usage = list_command_usage(name);
419    if let Some(rest) = usage
420        .strip_prefix('(')
421        .and_then(|rest| rest.split_once(") "))
422    {
423        Cow::Owned(rest.1.to_owned())
424    } else {
425        Cow::Borrowed(usage)
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn signatures_follow_parser_table_then_rmux_extensions() {
435        let names = list_command_names().collect::<Vec<_>>();
436        let parser_names = COMMAND_TABLE
437            .iter()
438            .map(|entry| entry.name)
439            .collect::<Vec<_>>();
440        assert_eq!(&names[..parser_names.len()], parser_names);
441        assert_eq!(
442            &names[parser_names.len()..],
443            RMUX_EXTENSION_COMMANDS
444                .iter()
445                .map(|entry| entry.name)
446                .collect::<Vec<_>>()
447        );
448    }
449
450    #[test]
451    fn short_option_specs_derive_boolean_and_value_boundaries_from_inventory() {
452        let capture = command_short_option_spec("capture-pane").expect("capture-pane signature");
453        for flag in ['a', 'e', 'J', 'p', 'q'] {
454            assert!(capture.is_boolean(flag), "capture-pane -{flag}");
455        }
456        for flag in ['b', 'E', 'S', 't'] {
457            assert!(capture.takes_value(flag), "capture-pane -{flag}");
458        }
459
460        let run_shell = command_short_option_spec("run-shell").expect("run-shell signature");
461        for flag in ['b', 'C', 'E'] {
462            assert!(run_shell.is_boolean(flag), "run-shell -{flag}");
463        }
464        for flag in ['c', 'd', 't'] {
465            assert!(run_shell.takes_value(flag), "run-shell -{flag}");
466        }
467
468        let wait_for = command_short_option_spec("wait-for").expect("wait-for signature");
469        for flag in ['L', 'S', 'U'] {
470            assert!(wait_for.is_boolean(flag), "wait-for -{flag}");
471        }
472
473        let list_clients =
474            command_short_option_spec("list-clients").expect("list-clients signature");
475        assert!(list_clients.takes_value('O'));
476        assert!(list_clients.takes_value('t'));
477
478        let split_window =
479            command_short_option_spec("split-window").expect("split-window signature");
480        assert!(split_window.is_boolean('k'));
481        assert!(split_window.takes_value('e'));
482        assert!(split_window.takes_value('l'));
483        assert!(split_window.takes_value('p'));
484        assert!(!split_window.is_boolean('e'));
485        assert!(!split_window.is_boolean('p'));
486
487        let load_buffer = command_short_option_spec("load-buffer").expect("load-buffer signature");
488        assert!(load_buffer.is_boolean('w'));
489        assert!(load_buffer.takes_value('b'));
490
491        let list_buffers =
492            command_short_option_spec("list-buffers").expect("list-buffers signature");
493        assert!(list_buffers.is_boolean('r'));
494        assert!(command_short_option_spec("not-a-command").is_none());
495    }
496
497    #[test]
498    fn internal_short_options_do_not_change_public_signatures() {
499        let load_buffer = LIST_COMMAND_SIGNATURES
500            .iter()
501            .find_map(|(name, usage)| (*name == "load-buffer").then_some(*usage))
502            .expect("load-buffer public signature");
503        let list_buffers = LIST_COMMAND_SIGNATURES
504            .iter()
505            .find_map(|(name, usage)| (*name == "list-buffers").then_some(*usage))
506            .expect("list-buffers public signature");
507
508        assert!(!load_buffer.contains("-w"));
509        assert!(!list_buffers.contains("-r"));
510    }
511
512    #[test]
513    fn short_option_inventory_has_unambiguous_value_boundaries() {
514        for (command_name, _) in LIST_COMMAND_SIGNATURES {
515            let spec = command_short_option_spec(command_name).expect("inventory command spec");
516            for flag in spec.boolean_flags.chars() {
517                assert!(
518                    !spec.value_flags.contains(flag),
519                    "{command_name} -{flag} is advertised as both boolean and value-taking"
520                );
521            }
522        }
523    }
524
525    #[test]
526    fn candidate_lookup_keeps_ambiguous_prefixes_and_exact_aliases() {
527        assert!(has_tmux_command_candidate("list"));
528        assert!(has_tmux_command_candidate("send"));
529        assert!(has_tmux_command_candidate("send-keys"));
530        assert!(!has_tmux_command_candidate("not-a-command"));
531        assert!(!has_tmux_command_candidate("FOO=bar"));
532    }
533
534    #[test]
535    fn formatted_list_commands_uses_command_list_fields_like_tmux() {
536        let rendered = render_list_commands_line(
537            Some(
538                "#{command_name}|#{command_alias}|#{command_list_name}|#{command_list_alias}|#{command_list_usage}",
539            ),
540            "swap-window",
541            Some("swapw"),
542        );
543        assert_eq!(
544            rendered,
545            "||swap-window|swapw|[-d] [-s src-window] [-t dst-window]"
546        );
547    }
548
549    #[test]
550    fn formatted_list_commands_preserves_tmux_escape_and_incomplete_rules() {
551        assert_eq!(
552            render_list_commands_line(
553                Some("##{command_list_name}|abc#{|#{command_list_name}"),
554                "link-window",
555                Some("linkw"),
556            ),
557            "#{command_list_name}|abc"
558        );
559        assert_eq!(
560            render_list_commands_line(
561                Some("abc#{|#{command_list_name}|tail"),
562                "link-window",
563                Some("linkw"),
564            ),
565            "abc"
566        );
567    }
568
569    #[test]
570    fn formatted_list_commands_supports_tmux_conditionals_and_modifiers() {
571        assert_eq!(
572            render_list_commands_line(
573                Some("#{?command_list_alias,alias,none}"),
574                "list-commands",
575                Some("lscm"),
576            ),
577            "alias"
578        );
579        assert_eq!(
580            render_list_commands_line(
581                Some("#{=5:command_list_name}"),
582                "list-commands",
583                Some("lscm"),
584            ),
585            "list-"
586        );
587        assert_eq!(
588            render_list_commands_line(
589                Some("#{?#{==:#{command_list_name},list-commands},#{command_list_alias},no}",),
590                "list-commands",
591                Some("lscm"),
592            ),
593            "lscm"
594        );
595    }
596
597    #[test]
598    fn explicit_lookup_resolves_aliases_extensions_and_errors() {
599        assert_eq!(
600            resolve_list_commands_target("neww").expect("neww resolves"),
601            "new-window"
602        );
603        assert_eq!(
604            resolve_list_commands_target("choose-session").expect("parser alias resolves"),
605            "choose-tree"
606        );
607        assert_eq!(
608            resolve_list_commands_target("web-share").expect("extension resolves"),
609            "web-share"
610        );
611        assert_eq!(
612            resolve_list_commands_target("nosuch"),
613            Err(ListCommandsError::Unknown("nosuch".to_owned()))
614        );
615        assert_eq!(
616            resolve_list_commands_target("list"),
617            Err(ListCommandsError::Ambiguous("list".to_owned()))
618        );
619        assert_eq!(
620            resolve_list_commands_target("wait-p"),
621            Err(ListCommandsError::Unknown("wait-p".to_owned()))
622        );
623    }
624
625    #[test]
626    fn bare_listing_hides_extensions_while_explicit_lookup_keeps_them() {
627        let bare = render_list_commands(Some("#{command_list_name}"), None)
628            .expect("bare inventory renders");
629        assert!(!bare.iter().any(|name| name == "web-share"));
630        assert_eq!(
631            render_list_commands(Some("#{command_list_name}"), Some("web-share"))
632                .expect("explicit extension renders"),
633            vec!["web-share"]
634        );
635    }
636}