1use 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#[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 #[must_use]
45 pub fn is_boolean(&self, flag: char) -> bool {
46 self.boolean_flags.contains(flag)
47 }
48
49 #[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 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 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#[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
171pub 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#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum ListCommandsError {
239 Unknown(String),
241 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
256pub 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
267pub 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#[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
329pub fn list_command_names() -> impl Iterator<Item = &'static str> {
331 LIST_COMMAND_SIGNATURES.iter().map(|(name, _)| *name)
332}
333
334#[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}