Skip to main content

rmux_core/
command_parser.rs

1//! tmux-compatible command tokenization and command-name lookup.
2//!
3//! This module mirrors the frozen tmux `cmd-parse.y` lexer boundary closely
4//! enough for RMUX command dispatch and config parsing to share one parser.
5//! Frozen source anchors: `/opt/rmux/reference/tmux` at commit
6//! `31d77e29b6c9fbb07d032018da78db3a8a38d979`, especially `cmd.c:121`
7//! (`cmd_table[]`) and `cmd-parse.y:1053`, `cmd-parse.y:1201`,
8//! `cmd-parse.y:1626` for argv parsing, continuation handling, and
9//! tokenization.
10
11use std::error::Error;
12use std::fmt;
13
14use crate::{
15    formats::{is_truthy, render_template, FormatVariable, FormatVariables},
16    EnvironmentStore,
17};
18
19#[path = "command_parser/aliases.rs"]
20mod aliases;
21#[path = "command_parser/grammar.rs"]
22mod grammar;
23#[path = "command_parser/lexer.rs"]
24mod lexer;
25#[path = "command_parser/lookup.rs"]
26mod lookup;
27#[path = "command_parser/table.rs"]
28mod table;
29
30use aliases::CommandAlias;
31use grammar::GrammarParser;
32use lexer::Lexer;
33use lookup::lookup_command_at;
34pub use table::{CommandEntry, COMMAND_TABLE};
35
36const DEFAULT_MAX_COMMAND_BYTES: usize = 16 * 1024;
37/// Maximum size of a single command parsed from `source-file` input.
38pub const SOURCE_FILE_MAX_COMMAND_BYTES: usize = 1024 * 1024;
39
40/// Parses a tmux command string with default expansion context.
41pub fn parse_command_string(input: &str) -> Result<ParsedCommands, CommandParseError> {
42    CommandParser::new().parse(input)
43}
44
45/// Parses a tmux command argument vector with default expansion context.
46pub fn parse_command_arguments<I, S>(arguments: I) -> Result<ParsedCommands, CommandParseError>
47where
48    I: IntoIterator<Item = S>,
49    S: AsRef<str>,
50{
51    CommandParser::new().parse_arguments(arguments)
52}
53
54/// Looks up a frozen tmux command using exact alias then unique name prefix.
55pub fn lookup_command(name: &str) -> Result<&'static CommandEntry, CommandParseError> {
56    lookup_command_at(name, 0)
57}
58
59/// Parser output for one command list.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ParsedCommands {
62    commands: Vec<ParsedCommand>,
63    assignments: Vec<EnvironmentAssignment>,
64    grouping: CommandGrouping,
65}
66
67impl ParsedCommands {
68    fn with_grouping(grouping: CommandGrouping) -> Self {
69        Self {
70            commands: Vec::new(),
71            assignments: Vec::new(),
72            grouping,
73        }
74    }
75
76    /// Returns the parsed command sequence.
77    #[must_use]
78    pub fn commands(&self) -> &[ParsedCommand] {
79        &self.commands
80    }
81
82    /// Returns parse-time environment assignments.
83    #[must_use]
84    pub fn assignments(&self) -> &[EnvironmentAssignment] {
85        &self.assignments
86    }
87
88    /// Returns how queue group IDs should be assigned for this parsed list.
89    #[must_use]
90    pub const fn grouping(&self) -> CommandGrouping {
91        self.grouping
92    }
93
94    /// Consumes the list and returns only the command sequence.
95    #[must_use]
96    pub fn into_commands(self) -> Vec<ParsedCommand> {
97        self.commands
98    }
99
100    /// Returns whether the parser found no executable commands.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.commands.is_empty()
104    }
105
106    fn push_assignment(&mut self, assignment: EnvironmentAssignment) {
107        self.assignments.push(assignment);
108    }
109
110    fn push_command(&mut self, command: ParsedCommand) {
111        self.commands.push(command);
112    }
113
114    /// Appends another parsed command list, preserving the grouping mode of
115    /// this list.
116    pub fn append(&mut self, mut other: Self) {
117        self.assignments.append(&mut other.assignments);
118        self.commands.append(&mut other.commands);
119    }
120
121    /// Adds an offset to every source line recorded in this command list.
122    ///
123    /// Recovery parsers use this after parsing a suffix of a larger source
124    /// file so diagnostics and verbose output still reference original lines.
125    pub fn add_line_offset(&mut self, offset: usize) {
126        if offset == 0 {
127            return;
128        }
129        for command in &mut self.commands {
130            command.add_line_offset(offset);
131        }
132    }
133
134    /// Converts the parsed commands back to a tmux-style command string.
135    #[must_use]
136    pub fn to_tmux_string(&self) -> String {
137        self.commands
138            .iter()
139            .map(ParsedCommand::to_tmux_string)
140            .collect::<Vec<_>>()
141            .join(" ; ")
142    }
143
144    /// Converts the parsed commands to a command string suitable for
145    /// embedding in a `bind-key` line.
146    #[must_use]
147    pub fn to_tmux_binding_string(&self) -> String {
148        self.commands
149            .iter()
150            .map(ParsedCommand::to_tmux_string)
151            .collect::<Vec<_>>()
152            .join(" \\; ")
153    }
154}
155
156/// Queue grouping mode captured while parsing a tmux command list.
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158pub enum CommandGrouping {
159    /// Commands that start on the same source line share one queue group.
160    #[default]
161    ByLine,
162    /// All commands in the parsed list share one queue group.
163    OneGroup,
164}
165
166/// One parsed tmux command with a canonical command name.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ParsedCommand {
169    name: String,
170    arguments: Vec<CommandArgument>,
171    line: usize,
172}
173
174impl ParsedCommand {
175    /// Returns the canonical command name.
176    #[must_use]
177    pub fn name(&self) -> &str {
178        &self.name
179    }
180
181    /// Returns the parsed command arguments.
182    #[must_use]
183    pub fn arguments(&self) -> &[CommandArgument] {
184        &self.arguments
185    }
186
187    /// Returns the one-based input line where this command started.
188    #[must_use]
189    pub fn line(&self) -> usize {
190        self.line
191    }
192
193    fn new(name: String, arguments: Vec<CommandArgument>, line: usize) -> Self {
194        Self {
195            name,
196            arguments,
197            line,
198        }
199    }
200
201    fn add_line_offset(&mut self, offset: usize) {
202        self.line = self.line.saturating_add(offset);
203        for argument in &mut self.arguments {
204            if let CommandArgument::Commands(commands) = argument {
205                commands.add_line_offset(offset);
206            }
207        }
208    }
209
210    /// Converts this command back to a tmux-style command string.
211    #[must_use]
212    pub fn to_tmux_string(&self) -> String {
213        std::iter::once(self.name.clone())
214            .chain(self.arguments.iter().map(CommandArgument::to_tmux_string))
215            .collect::<Vec<_>>()
216            .join(" ")
217    }
218}
219
220/// A parsed command argument.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum CommandArgument {
223    /// A scalar string argument after tmux quote and expansion handling.
224    String(String),
225    /// A brace-delimited nested command list.
226    Commands(ParsedCommands),
227}
228
229impl CommandArgument {
230    /// Returns the string value when this is a scalar argument.
231    #[must_use]
232    pub fn as_string(&self) -> Option<&str> {
233        match self {
234            Self::String(value) => Some(value),
235            Self::Commands(_) => None,
236        }
237    }
238
239    /// Converts the argument to a string suitable for the legacy CLI bridge.
240    #[must_use]
241    pub fn to_tmux_string(&self) -> String {
242        match self {
243            Self::String(value) => escape_argument(value),
244            Self::Commands(commands) => format!("{{ {} }}", commands.to_tmux_string()),
245        }
246    }
247}
248
249/// A parse-time `name=value` environment assignment.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct EnvironmentAssignment {
252    name: String,
253    value: String,
254    hidden: bool,
255}
256
257impl EnvironmentAssignment {
258    /// Returns the assignment variable name.
259    #[must_use]
260    pub fn name(&self) -> &str {
261        &self.name
262    }
263
264    /// Returns the assignment value.
265    #[must_use]
266    pub fn value(&self) -> &str {
267        &self.value
268    }
269
270    /// Returns whether `%hidden` preceded this assignment.
271    #[must_use]
272    pub fn hidden(&self) -> bool {
273        self.hidden
274    }
275
276    fn from_equals(value: String, hidden: bool) -> Self {
277        let (name, value) = value
278            .split_once('=')
279            .expect("lexer only classifies assignments containing '='");
280        Self {
281            name: name.to_owned(),
282            value: value.to_owned(),
283            hidden,
284        }
285    }
286}
287
288/// A reusable parser with parse-time expansion context.
289#[derive(Debug, Clone)]
290pub struct CommandParser {
291    environment: Vec<(String, String)>,
292    format_variables: Vec<(String, String)>,
293    home_dir: Option<String>,
294    user_home_dirs: Vec<(String, String)>,
295    command_aliases: Vec<CommandAlias>,
296    max_command_bytes: usize,
297}
298
299impl Default for CommandParser {
300    fn default() -> Self {
301        Self {
302            environment: Vec::new(),
303            format_variables: Vec::new(),
304            home_dir: None,
305            user_home_dirs: Vec::new(),
306            command_aliases: Vec::new(),
307            max_command_bytes: DEFAULT_MAX_COMMAND_BYTES,
308        }
309    }
310}
311
312impl CommandParser {
313    /// Creates a parser with no environment, tilde, or user alias overrides.
314    #[must_use]
315    pub fn new() -> Self {
316        let mut parser = Self::default();
317        parser.command_aliases.extend(CommandAlias::builtin());
318        parser
319    }
320
321    /// Adds one variable to the parse-time environment expansion context.
322    #[must_use]
323    pub fn with_environment_value(
324        mut self,
325        name: impl Into<String>,
326        value: impl Into<String>,
327    ) -> Self {
328        self.environment.push((name.into(), value.into()));
329        self
330    }
331
332    /// Adds one parse-time format variable used by `%if` condition expansion.
333    #[must_use]
334    pub fn with_format_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
335        self.format_variables.push((name.into(), value.into()));
336        self
337    }
338
339    /// Copies global values from an RMUX environment store into the parser.
340    #[must_use]
341    pub fn with_environment_store(mut self, environment: &EnvironmentStore) -> Self {
342        self.environment.extend(
343            environment
344                .global_entries()
345                .map(|(name, value)| (name.to_owned(), value.to_owned())),
346        );
347        self
348    }
349
350    /// Adds the fallback home directory used for `~` expansion.
351    #[must_use]
352    pub fn with_home_dir(mut self, home_dir: impl Into<String>) -> Self {
353        self.home_dir = Some(home_dir.into());
354        self
355    }
356
357    /// Adds a deterministic `~user` expansion mapping.
358    #[must_use]
359    pub fn with_user_home_dir(
360        mut self,
361        user: impl Into<String>,
362        home_dir: impl Into<String>,
363    ) -> Self {
364        self.user_home_dirs.push((user.into(), home_dir.into()));
365        self
366    }
367
368    /// Adds one `command-alias` option entry of the form `name=value`.
369    pub fn with_command_alias(
370        mut self,
371        definition: impl Into<String>,
372    ) -> Result<Self, CommandParseError> {
373        let definition = definition.into();
374        let Some(alias) = CommandAlias::parse(definition) else {
375            return Err(CommandParseError::new(
376                0,
377                "command-alias entry must be name=value",
378            ));
379        };
380        self.command_aliases.push(alias);
381        Ok(self)
382    }
383
384    /// Replaces the parser alias table with valid `command-alias` entries.
385    #[must_use]
386    pub fn with_command_aliases<I, S>(mut self, definitions: I) -> Self
387    where
388        I: IntoIterator<Item = S>,
389        S: Into<String>,
390    {
391        self.command_aliases.clear();
392        self.command_aliases
393            .extend(definitions.into_iter().filter_map(CommandAlias::parse));
394        self
395    }
396
397    /// Overrides the maximum parsed command size.
398    #[must_use]
399    pub fn with_max_command_bytes(mut self, max_command_bytes: usize) -> Self {
400        self.max_command_bytes = max_command_bytes;
401        self
402    }
403
404    /// Parses a tmux command string through the tmux-style lexer.
405    pub fn parse(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
406        self.parse_inner(input, false, CommandGrouping::ByLine)
407    }
408
409    /// Parses a tmux command string with `CMD_PARSE_ONEGROUP` semantics.
410    ///
411    /// tmux uses this mode when a command string is parsed from an argument or
412    /// option value, so embedded newlines do not create independent abort
413    /// groups.
414    pub fn parse_one_group(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
415        self.parse_inner(input, false, CommandGrouping::OneGroup)
416    }
417
418    /// Parses an argv-style tmux command vector.
419    ///
420    /// tmux treats these arguments as already split and only divides commands
421    /// on unescaped trailing semicolons.
422    pub fn parse_arguments<I, S>(&self, arguments: I) -> Result<ParsedCommands, CommandParseError>
423    where
424        I: IntoIterator<Item = S>,
425        S: AsRef<str>,
426    {
427        let arguments = arguments
428            .into_iter()
429            .map(|argument| argument.as_ref().to_owned())
430            .collect::<Vec<_>>();
431        let command_bytes = arguments
432            .iter()
433            .map(String::len)
434            .sum::<usize>()
435            .saturating_add(arguments.len().saturating_sub(1));
436        ensure_command_length(command_bytes, 0, self.max_command_bytes)?;
437
438        let mut commands = ParsedCommands::with_grouping(CommandGrouping::ByLine);
439        let mut current = Vec::new();
440
441        for argument in arguments {
442            let mut value = argument;
443            let mut ends_command = false;
444
445            if value.ends_with(';') {
446                value.pop();
447                if value.ends_with('\\') {
448                    value.pop();
449                    value.push(';');
450                } else {
451                    ends_command = true;
452                }
453            }
454
455            if !ends_command || !value.is_empty() {
456                current.push(CommandArgument::String(value));
457            }
458            if ends_command && !current.is_empty() {
459                commands.push_command(command_from_arguments(std::mem::take(&mut current), 1)?);
460            }
461        }
462
463        if !current.is_empty() {
464            commands.push_command(command_from_arguments(current, 1)?);
465        }
466
467        self.expand_and_lookup(commands, false)
468    }
469
470    fn parse_inner(
471        &self,
472        input: &str,
473        no_alias: bool,
474        grouping: CommandGrouping,
475    ) -> Result<ParsedCommands, CommandParseError> {
476        let mut parser = GrammarParser::new(Lexer::new(input, self), grouping);
477        let commands = parser.parse_all()?;
478        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
479        self.expand_and_lookup(commands, no_alias)
480    }
481
482    fn expand_and_lookup(
483        &self,
484        commands: ParsedCommands,
485        no_alias: bool,
486    ) -> Result<ParsedCommands, CommandParseError> {
487        let assignments = commands.assignments.clone();
488        let mut output = ParsedCommands {
489            commands: Vec::new(),
490            assignments: commands.assignments,
491            grouping: commands.grouping,
492        };
493
494        for mut command in commands.commands {
495            if !no_alias {
496                if let Some(alias) = self.find_command_alias(&command.name) {
497                    let mut alias_parser = self.clone();
498                    alias_parser.environment.extend(
499                        assignments
500                            .iter()
501                            .map(|assignment| (assignment.name.clone(), assignment.value.clone())),
502                    );
503                    let mut replacement =
504                        alias_parser.parse_inner(alias, true, CommandGrouping::OneGroup)?;
505                    for replacement_command in &mut replacement.commands {
506                        replacement_command.line = command.line;
507                    }
508                    if let Some(last) = replacement.commands.last_mut() {
509                        last.arguments.append(&mut command.arguments);
510                    }
511                    output.append(replacement);
512                    continue;
513                }
514            }
515
516            for argument in &mut command.arguments {
517                if let CommandArgument::Commands(nested) = argument {
518                    let nested_commands = std::mem::take(nested);
519                    *nested = self.expand_and_lookup(nested_commands, no_alias)?;
520                }
521            }
522
523            let entry = lookup_command_at(&command.name, command.line)?;
524            command.name = entry.name.to_owned();
525            output.push_command(command);
526        }
527
528        Ok(output)
529    }
530
531    fn find_command_alias(&self, name: &str) -> Option<&str> {
532        self.command_aliases
533            .iter()
534            .rev()
535            .find(|alias| alias.name() == name)
536            .map(CommandAlias::value)
537    }
538
539    fn lookup_environment(&self, name: &str) -> Option<&str> {
540        self.environment
541            .iter()
542            .rev()
543            .find(|(candidate, _)| candidate == name)
544            .map(|(_, value)| value.as_str())
545    }
546
547    fn expand_tilde(&self, user: &str) -> Option<&str> {
548        if user.is_empty() {
549            return self
550                .lookup_environment("HOME")
551                .filter(|home| !home.is_empty())
552                .or(self.home_dir.as_deref());
553        }
554
555        self.user_home_dirs
556            .iter()
557            .find(|(candidate, _)| candidate == user)
558            .map(|(_, home)| home.as_str())
559    }
560
561    fn condition_is_true(&self, value: &str) -> bool {
562        let expanded = if value.contains("#{") {
563            render_template(
564                value,
565                &ParseTimeFormatVariables {
566                    values: &self.format_variables,
567                },
568            )
569        } else {
570            value.to_owned()
571        };
572
573        is_truthy(&expanded)
574    }
575}
576
577fn ensure_command_length(
578    bytes: usize,
579    line: usize,
580    max_command_bytes: usize,
581) -> Result<(), CommandParseError> {
582    if bytes > max_command_bytes {
583        return Err(CommandParseError::new(line, "command too long"));
584    }
585    Ok(())
586}
587
588fn ensure_parsed_command_lengths(
589    commands: &ParsedCommands,
590    max_command_bytes: usize,
591) -> Result<(), CommandParseError> {
592    for command in commands.commands() {
593        ensure_parsed_command_length(command, max_command_bytes)?;
594    }
595    Ok(())
596}
597
598fn ensure_parsed_command_length(
599    command: &ParsedCommand,
600    max_command_bytes: usize,
601) -> Result<(), CommandParseError> {
602    let mut bytes = command.name.len();
603    for argument in command.arguments() {
604        bytes = bytes.saturating_add(1);
605        match argument {
606            CommandArgument::String(value) => {
607                bytes = bytes.saturating_add(value.len());
608            }
609            CommandArgument::Commands(commands) => {
610                ensure_parsed_command_lengths(commands, max_command_bytes)?;
611            }
612        }
613    }
614    ensure_command_length(bytes, command.line(), max_command_bytes)
615}
616
617struct ParseTimeFormatVariables<'a> {
618    values: &'a [(String, String)],
619}
620
621impl FormatVariables for ParseTimeFormatVariables<'_> {
622    fn format_value(&self, _variable: FormatVariable) -> Option<String> {
623        None
624    }
625
626    fn format_value_by_name(&self, name: &str) -> Option<String> {
627        self.values
628            .iter()
629            .rev()
630            .find(|(candidate, _)| candidate == name)
631            .map(|(_, value)| value.clone())
632    }
633}
634
635/// Error returned by command tokenization, parsing, or lookup.
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub struct CommandParseError {
638    line: usize,
639    message: String,
640}
641
642impl CommandParseError {
643    /// Returns the one-based input line for the error, or zero when unknown.
644    #[must_use]
645    pub fn line(&self) -> usize {
646        self.line
647    }
648
649    /// Returns the tmux-style error message.
650    #[must_use]
651    pub fn message(&self) -> &str {
652        &self.message
653    }
654
655    pub(crate) fn new(line: usize, message: impl Into<String>) -> Self {
656        Self {
657            line,
658            message: message.into(),
659        }
660    }
661}
662
663impl fmt::Display for CommandParseError {
664    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
665        formatter.write_str(&self.message)
666    }
667}
668
669impl Error for CommandParseError {}
670
671fn command_from_arguments(
672    mut arguments: Vec<CommandArgument>,
673    line: usize,
674) -> Result<ParsedCommand, CommandParseError> {
675    let Some(CommandArgument::String(name)) = arguments.first() else {
676        return Err(CommandParseError::new(line, "no command"));
677    };
678    let name = name.clone();
679    arguments.remove(0);
680    Ok(ParsedCommand::new(name, arguments, line))
681}
682
683fn escape_argument(value: &str) -> String {
684    if value.is_empty() {
685        return "''".to_owned();
686    }
687    if !value.chars().any(argument_needs_quotes) {
688        return value.replace('\\', r"\\");
689    }
690
691    if value.contains('"') && !value.contains('\'') && !value.contains('$') {
692        return format!("'{value}'");
693    }
694
695    format!("\"{}\"", escape_double_quoted_argument(value))
696}
697
698fn argument_needs_quotes(ch: char) -> bool {
699    ch.is_whitespace() || matches!(ch, ';' | '{' | '}' | '\'' | '"' | '#' | '$')
700}
701
702fn escape_double_quoted_argument(value: &str) -> String {
703    let mut escaped = String::with_capacity(value.len());
704    for ch in value.chars() {
705        match ch {
706            '$' => escaped.push_str(r"\$"),
707            '\\' | '"' => {
708                escaped.push('\\');
709                escaped.push(ch);
710            }
711            _ => escaped.push(ch),
712        }
713    }
714    escaped
715}
716
717#[cfg(test)]
718#[path = "command_parser/tests.rs"]
719mod tests;