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    exact_commands: &'static [CommandEntry],
297    max_command_bytes: usize,
298}
299
300impl Default for CommandParser {
301    fn default() -> Self {
302        Self {
303            environment: Vec::new(),
304            format_variables: Vec::new(),
305            home_dir: None,
306            user_home_dirs: Vec::new(),
307            command_aliases: Vec::new(),
308            exact_commands: &[],
309            max_command_bytes: DEFAULT_MAX_COMMAND_BYTES,
310        }
311    }
312}
313
314impl CommandParser {
315    /// Creates a parser with no environment, tilde, or user alias overrides.
316    #[must_use]
317    pub fn new() -> Self {
318        let mut parser = Self::default();
319        parser.command_aliases.extend(CommandAlias::builtin());
320        parser
321    }
322
323    /// Adds one variable to the parse-time environment expansion context.
324    #[must_use]
325    pub fn with_environment_value(
326        mut self,
327        name: impl Into<String>,
328        value: impl Into<String>,
329    ) -> Self {
330        self.environment.push((name.into(), value.into()));
331        self
332    }
333
334    /// Adds one parse-time format variable used by `%if` condition expansion.
335    #[must_use]
336    pub fn with_format_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
337        self.format_variables.push((name.into(), value.into()));
338        self
339    }
340
341    /// Copies global values from an RMUX environment store into the parser.
342    #[must_use]
343    pub fn with_environment_store(mut self, environment: &EnvironmentStore) -> Self {
344        self.environment.extend(
345            environment
346                .global_entries()
347                .map(|(name, value)| (name.to_owned(), value.to_owned())),
348        );
349        self
350    }
351
352    /// Adds the fallback home directory used for `~` expansion.
353    #[must_use]
354    pub fn with_home_dir(mut self, home_dir: impl Into<String>) -> Self {
355        self.home_dir = Some(home_dir.into());
356        self
357    }
358
359    /// Adds a deterministic `~user` expansion mapping.
360    #[must_use]
361    pub fn with_user_home_dir(
362        mut self,
363        user: impl Into<String>,
364        home_dir: impl Into<String>,
365    ) -> Self {
366        self.user_home_dirs.push((user.into(), home_dir.into()));
367        self
368    }
369
370    /// Adds one `command-alias` option entry of the form `name=value`.
371    pub fn with_command_alias(
372        mut self,
373        definition: impl Into<String>,
374    ) -> Result<Self, CommandParseError> {
375        let definition = definition.into();
376        let Some(alias) = CommandAlias::parse(definition) else {
377            return Err(CommandParseError::new(
378                0,
379                "command-alias entry must be name=value",
380            ));
381        };
382        self.command_aliases.push(alias);
383        Ok(self)
384    }
385
386    /// Replaces the parser alias table with valid `command-alias` entries.
387    #[must_use]
388    pub fn with_command_aliases<I, S>(mut self, definitions: I) -> Self
389    where
390        I: IntoIterator<Item = S>,
391        S: Into<String>,
392    {
393        self.command_aliases.clear();
394        self.command_aliases
395            .extend(definitions.into_iter().filter_map(CommandAlias::parse));
396        self
397    }
398
399    /// Adds exact-only command names to this parser.
400    ///
401    /// These entries are intentionally excluded from tmux-style prefix lookup.
402    /// Use this for client-side RMUX extensions; server-side `source-file`
403    /// parsing should keep the frozen tmux command table.
404    #[must_use]
405    pub fn with_exact_commands(mut self, commands: &'static [CommandEntry]) -> Self {
406        self.exact_commands = commands;
407        self
408    }
409
410    /// Overrides the maximum parsed command size.
411    #[must_use]
412    pub fn with_max_command_bytes(mut self, max_command_bytes: usize) -> Self {
413        self.max_command_bytes = max_command_bytes;
414        self
415    }
416
417    /// Parses a tmux command string through the tmux-style lexer.
418    pub fn parse(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
419        self.parse_inner(input, false, CommandGrouping::ByLine)
420    }
421
422    /// Parses a tmux command string with `CMD_PARSE_ONEGROUP` semantics.
423    ///
424    /// tmux uses this mode when a command string is parsed from an argument or
425    /// option value, so embedded newlines do not create independent abort
426    /// groups.
427    pub fn parse_one_group(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
428        self.parse_inner(input, false, CommandGrouping::OneGroup)
429    }
430
431    /// Parses an argv-style tmux command vector.
432    ///
433    /// tmux treats these arguments as already split and only divides commands
434    /// on unescaped trailing semicolons.
435    pub fn parse_arguments<I, S>(&self, arguments: I) -> Result<ParsedCommands, CommandParseError>
436    where
437        I: IntoIterator<Item = S>,
438        S: AsRef<str>,
439    {
440        let arguments = arguments
441            .into_iter()
442            .map(|argument| argument.as_ref().to_owned())
443            .collect::<Vec<_>>();
444        let command_bytes = arguments
445            .iter()
446            .map(String::len)
447            .sum::<usize>()
448            .saturating_add(arguments.len().saturating_sub(1));
449        ensure_command_length(command_bytes, 0, self.max_command_bytes)?;
450
451        let mut commands = ParsedCommands::with_grouping(CommandGrouping::ByLine);
452        let mut current = Vec::new();
453
454        for argument in arguments {
455            let mut value = argument;
456            let mut ends_command = false;
457
458            if value.ends_with(';') {
459                value.pop();
460                if value.ends_with('\\') {
461                    value.pop();
462                    value.push(';');
463                } else {
464                    ends_command = true;
465                }
466            }
467
468            if !ends_command || !value.is_empty() {
469                current.push(CommandArgument::String(value));
470            }
471            if ends_command && !current.is_empty() {
472                commands.push_command(command_from_arguments(std::mem::take(&mut current), 1)?);
473            }
474        }
475
476        if !current.is_empty() {
477            commands.push_command(command_from_arguments(current, 1)?);
478        }
479
480        self.expand_and_lookup(commands, false)
481    }
482
483    fn parse_inner(
484        &self,
485        input: &str,
486        no_alias: bool,
487        grouping: CommandGrouping,
488    ) -> Result<ParsedCommands, CommandParseError> {
489        let mut parser = GrammarParser::new(Lexer::new(input, self), grouping);
490        let commands = parser.parse_all()?;
491        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
492        self.expand_and_lookup(commands, no_alias)
493    }
494
495    fn expand_and_lookup(
496        &self,
497        commands: ParsedCommands,
498        no_alias: bool,
499    ) -> Result<ParsedCommands, CommandParseError> {
500        let assignments = commands.assignments.clone();
501        let mut output = ParsedCommands {
502            commands: Vec::new(),
503            assignments: commands.assignments,
504            grouping: commands.grouping,
505        };
506
507        for mut command in commands.commands {
508            if !no_alias {
509                if let Some(alias) = self.find_command_alias(&command.name) {
510                    let mut alias_parser = self.clone();
511                    alias_parser.environment.extend(
512                        assignments
513                            .iter()
514                            .map(|assignment| (assignment.name.clone(), assignment.value.clone())),
515                    );
516                    let mut replacement =
517                        alias_parser.parse_inner(alias, true, CommandGrouping::OneGroup)?;
518                    for replacement_command in &mut replacement.commands {
519                        replacement_command.line = command.line;
520                    }
521                    if let Some(last) = replacement.commands.last_mut() {
522                        last.arguments.append(&mut command.arguments);
523                    }
524                    output.append(replacement);
525                    continue;
526                }
527            }
528
529            for argument in &mut command.arguments {
530                if let CommandArgument::Commands(nested) = argument {
531                    let nested_commands = std::mem::take(nested);
532                    *nested = self.expand_and_lookup(nested_commands, no_alias)?;
533                }
534            }
535
536            let entry = lookup_command_at(&command.name, command.line, self.exact_commands)?;
537            command.name = entry.name.to_owned();
538            output.push_command(command);
539        }
540
541        Ok(output)
542    }
543
544    fn find_command_alias(&self, name: &str) -> Option<&str> {
545        self.command_aliases
546            .iter()
547            .rev()
548            .find(|alias| alias.name() == name)
549            .map(CommandAlias::value)
550    }
551
552    fn lookup_environment(&self, name: &str) -> Option<&str> {
553        self.environment
554            .iter()
555            .rev()
556            .find(|(candidate, _)| candidate == name)
557            .map(|(_, value)| value.as_str())
558    }
559
560    fn expand_tilde(&self, user: &str) -> Option<&str> {
561        if user.is_empty() {
562            return self
563                .lookup_environment("HOME")
564                .filter(|home| !home.is_empty())
565                .or(self.home_dir.as_deref());
566        }
567
568        self.user_home_dirs
569            .iter()
570            .find(|(candidate, _)| candidate == user)
571            .map(|(_, home)| home.as_str())
572    }
573
574    fn condition_is_true(&self, value: &str) -> bool {
575        let expanded = if value.contains("#{") {
576            render_template(
577                value,
578                &ParseTimeFormatVariables {
579                    values: &self.format_variables,
580                },
581            )
582        } else {
583            value.to_owned()
584        };
585
586        is_truthy(&expanded)
587    }
588}
589
590fn ensure_command_length(
591    bytes: usize,
592    line: usize,
593    max_command_bytes: usize,
594) -> Result<(), CommandParseError> {
595    if bytes > max_command_bytes {
596        return Err(CommandParseError::new(line, "command too long"));
597    }
598    Ok(())
599}
600
601fn ensure_parsed_command_lengths(
602    commands: &ParsedCommands,
603    max_command_bytes: usize,
604) -> Result<(), CommandParseError> {
605    for command in commands.commands() {
606        ensure_parsed_command_length(command, max_command_bytes)?;
607    }
608    Ok(())
609}
610
611fn ensure_parsed_command_length(
612    command: &ParsedCommand,
613    max_command_bytes: usize,
614) -> Result<(), CommandParseError> {
615    let mut bytes = command.name.len();
616    for argument in command.arguments() {
617        bytes = bytes.saturating_add(1);
618        match argument {
619            CommandArgument::String(value) => {
620                bytes = bytes.saturating_add(value.len());
621            }
622            CommandArgument::Commands(commands) => {
623                ensure_parsed_command_lengths(commands, max_command_bytes)?;
624            }
625        }
626    }
627    ensure_command_length(bytes, command.line(), max_command_bytes)
628}
629
630struct ParseTimeFormatVariables<'a> {
631    values: &'a [(String, String)],
632}
633
634impl FormatVariables for ParseTimeFormatVariables<'_> {
635    fn format_value(&self, _variable: FormatVariable) -> Option<String> {
636        None
637    }
638
639    fn format_value_by_name(&self, name: &str) -> Option<String> {
640        self.values
641            .iter()
642            .rev()
643            .find(|(candidate, _)| candidate == name)
644            .map(|(_, value)| value.clone())
645    }
646}
647
648/// Error returned by command tokenization, parsing, or lookup.
649#[derive(Debug, Clone, PartialEq, Eq)]
650pub struct CommandParseError {
651    line: usize,
652    message: String,
653}
654
655impl CommandParseError {
656    /// Returns the one-based input line for the error, or zero when unknown.
657    #[must_use]
658    pub fn line(&self) -> usize {
659        self.line
660    }
661
662    /// Returns the tmux-style error message.
663    #[must_use]
664    pub fn message(&self) -> &str {
665        &self.message
666    }
667
668    pub(crate) fn new(line: usize, message: impl Into<String>) -> Self {
669        Self {
670            line,
671            message: message.into(),
672        }
673    }
674}
675
676impl fmt::Display for CommandParseError {
677    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
678        formatter.write_str(&self.message)
679    }
680}
681
682impl Error for CommandParseError {}
683
684fn command_from_arguments(
685    mut arguments: Vec<CommandArgument>,
686    line: usize,
687) -> Result<ParsedCommand, CommandParseError> {
688    let Some(CommandArgument::String(name)) = arguments.first() else {
689        return Err(CommandParseError::new(line, "no command"));
690    };
691    let name = name.clone();
692    arguments.remove(0);
693    Ok(ParsedCommand::new(name, arguments, line))
694}
695
696fn escape_argument(value: &str) -> String {
697    if value.is_empty() {
698        return "''".to_owned();
699    }
700    if !value.chars().any(argument_needs_quotes) {
701        return value.replace('\\', r"\\");
702    }
703
704    if value.contains('"') && !value.contains('\'') && !value.contains('$') {
705        return format!("'{value}'");
706    }
707
708    format!("\"{}\"", escape_double_quoted_argument(value))
709}
710
711fn argument_needs_quotes(ch: char) -> bool {
712    ch.is_whitespace() || matches!(ch, ';' | '{' | '}' | '\'' | '"' | '#' | '$')
713}
714
715fn escape_double_quoted_argument(value: &str) -> String {
716    let mut escaped = String::with_capacity(value.len());
717    for ch in value.chars() {
718        match ch {
719            '$' => escaped.push_str(r"\$"),
720            '\\' | '"' => {
721                escaped.push('\\');
722                escaped.push(ch);
723            }
724            _ => escaped.push(ch),
725        }
726    }
727    escaped
728}
729
730#[cfg(test)]
731#[path = "command_parser/tests.rs"]
732mod tests;