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
36/// Parses a tmux command string with default expansion context.
37pub fn parse_command_string(input: &str) -> Result<ParsedCommands, CommandParseError> {
38    CommandParser::new().parse(input)
39}
40
41/// Parses a tmux command argument vector with default expansion context.
42pub fn parse_command_arguments<I, S>(arguments: I) -> Result<ParsedCommands, CommandParseError>
43where
44    I: IntoIterator<Item = S>,
45    S: AsRef<str>,
46{
47    CommandParser::new().parse_arguments(arguments)
48}
49
50/// Looks up a frozen tmux command using exact alias then unique name prefix.
51pub fn lookup_command(name: &str) -> Result<&'static CommandEntry, CommandParseError> {
52    lookup_command_at(name, 0)
53}
54
55/// Parser output for one command list.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct ParsedCommands {
58    commands: Vec<ParsedCommand>,
59    assignments: Vec<EnvironmentAssignment>,
60    grouping: CommandGrouping,
61}
62
63impl ParsedCommands {
64    fn with_grouping(grouping: CommandGrouping) -> Self {
65        Self {
66            commands: Vec::new(),
67            assignments: Vec::new(),
68            grouping,
69        }
70    }
71
72    /// Returns the parsed command sequence.
73    #[must_use]
74    pub fn commands(&self) -> &[ParsedCommand] {
75        &self.commands
76    }
77
78    /// Returns parse-time environment assignments.
79    #[must_use]
80    pub fn assignments(&self) -> &[EnvironmentAssignment] {
81        &self.assignments
82    }
83
84    /// Returns how queue group IDs should be assigned for this parsed list.
85    #[must_use]
86    pub const fn grouping(&self) -> CommandGrouping {
87        self.grouping
88    }
89
90    /// Consumes the list and returns only the command sequence.
91    #[must_use]
92    pub fn into_commands(self) -> Vec<ParsedCommand> {
93        self.commands
94    }
95
96    /// Returns whether the parser found no executable commands.
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.commands.is_empty()
100    }
101
102    fn push_assignment(&mut self, assignment: EnvironmentAssignment) {
103        self.assignments.push(assignment);
104    }
105
106    fn push_command(&mut self, command: ParsedCommand) {
107        self.commands.push(command);
108    }
109
110    /// Appends another parsed command list, preserving the grouping mode of
111    /// this list.
112    pub fn append(&mut self, mut other: Self) {
113        self.assignments.append(&mut other.assignments);
114        self.commands.append(&mut other.commands);
115    }
116
117    /// Converts the parsed commands back to a tmux-style command string.
118    #[must_use]
119    pub fn to_tmux_string(&self) -> String {
120        self.commands
121            .iter()
122            .map(ParsedCommand::to_tmux_string)
123            .collect::<Vec<_>>()
124            .join(" ; ")
125    }
126}
127
128/// Queue grouping mode captured while parsing a tmux command list.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub enum CommandGrouping {
131    /// Commands that start on the same source line share one queue group.
132    #[default]
133    ByLine,
134    /// All commands in the parsed list share one queue group.
135    OneGroup,
136}
137
138/// One parsed tmux command with a canonical command name.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ParsedCommand {
141    name: String,
142    arguments: Vec<CommandArgument>,
143    line: usize,
144}
145
146impl ParsedCommand {
147    /// Returns the canonical command name.
148    #[must_use]
149    pub fn name(&self) -> &str {
150        &self.name
151    }
152
153    /// Returns the parsed command arguments.
154    #[must_use]
155    pub fn arguments(&self) -> &[CommandArgument] {
156        &self.arguments
157    }
158
159    /// Returns the one-based input line where this command started.
160    #[must_use]
161    pub fn line(&self) -> usize {
162        self.line
163    }
164
165    fn new(name: String, arguments: Vec<CommandArgument>, line: usize) -> Self {
166        Self {
167            name,
168            arguments,
169            line,
170        }
171    }
172
173    fn to_tmux_string(&self) -> String {
174        std::iter::once(self.name.clone())
175            .chain(self.arguments.iter().map(CommandArgument::to_tmux_string))
176            .collect::<Vec<_>>()
177            .join(" ")
178    }
179}
180
181/// A parsed command argument.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum CommandArgument {
184    /// A scalar string argument after tmux quote and expansion handling.
185    String(String),
186    /// A brace-delimited nested command list.
187    Commands(ParsedCommands),
188}
189
190impl CommandArgument {
191    /// Returns the string value when this is a scalar argument.
192    #[must_use]
193    pub fn as_string(&self) -> Option<&str> {
194        match self {
195            Self::String(value) => Some(value),
196            Self::Commands(_) => None,
197        }
198    }
199
200    /// Converts the argument to a string suitable for the legacy CLI bridge.
201    #[must_use]
202    pub fn to_tmux_string(&self) -> String {
203        match self {
204            Self::String(value) => escape_argument(value),
205            Self::Commands(commands) => format!("{{ {} }}", commands.to_tmux_string()),
206        }
207    }
208}
209
210/// A parse-time `name=value` environment assignment.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct EnvironmentAssignment {
213    name: String,
214    value: String,
215    hidden: bool,
216}
217
218impl EnvironmentAssignment {
219    /// Returns the assignment variable name.
220    #[must_use]
221    pub fn name(&self) -> &str {
222        &self.name
223    }
224
225    /// Returns the assignment value.
226    #[must_use]
227    pub fn value(&self) -> &str {
228        &self.value
229    }
230
231    /// Returns whether `%hidden` preceded this assignment.
232    #[must_use]
233    pub fn hidden(&self) -> bool {
234        self.hidden
235    }
236
237    fn from_equals(value: String, hidden: bool) -> Self {
238        let (name, value) = value
239            .split_once('=')
240            .expect("lexer only classifies assignments containing '='");
241        Self {
242            name: name.to_owned(),
243            value: value.to_owned(),
244            hidden,
245        }
246    }
247}
248
249/// A reusable parser with parse-time expansion context.
250#[derive(Debug, Clone, Default)]
251pub struct CommandParser {
252    environment: Vec<(String, String)>,
253    format_variables: Vec<(String, String)>,
254    home_dir: Option<String>,
255    user_home_dirs: Vec<(String, String)>,
256    command_aliases: Vec<CommandAlias>,
257}
258
259impl CommandParser {
260    /// Creates a parser with no environment, tilde, or user alias overrides.
261    #[must_use]
262    pub fn new() -> Self {
263        let mut parser = Self::default();
264        parser.command_aliases.extend(CommandAlias::builtin());
265        parser
266    }
267
268    /// Adds one variable to the parse-time environment expansion context.
269    #[must_use]
270    pub fn with_environment_value(
271        mut self,
272        name: impl Into<String>,
273        value: impl Into<String>,
274    ) -> Self {
275        self.environment.push((name.into(), value.into()));
276        self
277    }
278
279    /// Adds one parse-time format variable used by `%if` condition expansion.
280    #[must_use]
281    pub fn with_format_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
282        self.format_variables.push((name.into(), value.into()));
283        self
284    }
285
286    /// Copies global values from an RMUX environment store into the parser.
287    #[must_use]
288    pub fn with_environment_store(mut self, environment: &EnvironmentStore) -> Self {
289        self.environment.extend(
290            environment
291                .global_entries()
292                .map(|(name, value)| (name.to_owned(), value.to_owned())),
293        );
294        self
295    }
296
297    /// Adds the fallback home directory used for `~` expansion.
298    #[must_use]
299    pub fn with_home_dir(mut self, home_dir: impl Into<String>) -> Self {
300        self.home_dir = Some(home_dir.into());
301        self
302    }
303
304    /// Adds a deterministic `~user` expansion mapping.
305    #[must_use]
306    pub fn with_user_home_dir(
307        mut self,
308        user: impl Into<String>,
309        home_dir: impl Into<String>,
310    ) -> Self {
311        self.user_home_dirs.push((user.into(), home_dir.into()));
312        self
313    }
314
315    /// Adds one `command-alias` option entry of the form `name=value`.
316    pub fn with_command_alias(
317        mut self,
318        definition: impl Into<String>,
319    ) -> Result<Self, CommandParseError> {
320        let definition = definition.into();
321        let Some(alias) = CommandAlias::parse(definition) else {
322            return Err(CommandParseError::new(
323                0,
324                "command-alias entry must be name=value",
325            ));
326        };
327        self.command_aliases.push(alias);
328        Ok(self)
329    }
330
331    /// Replaces the parser alias table with valid `command-alias` entries.
332    #[must_use]
333    pub fn with_command_aliases<I, S>(mut self, definitions: I) -> Self
334    where
335        I: IntoIterator<Item = S>,
336        S: Into<String>,
337    {
338        self.command_aliases.clear();
339        self.command_aliases
340            .extend(definitions.into_iter().filter_map(CommandAlias::parse));
341        self
342    }
343
344    /// Parses a tmux command string through the tmux-style lexer.
345    pub fn parse(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
346        self.parse_inner(input, false, CommandGrouping::ByLine)
347    }
348
349    /// Parses a tmux command string with `CMD_PARSE_ONEGROUP` semantics.
350    ///
351    /// tmux uses this mode when a command string is parsed from an argument or
352    /// option value, so embedded newlines do not create independent abort
353    /// groups.
354    pub fn parse_one_group(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
355        self.parse_inner(input, false, CommandGrouping::OneGroup)
356    }
357
358    /// Parses an argv-style tmux command vector.
359    ///
360    /// tmux treats these arguments as already split and only divides commands
361    /// on unescaped trailing semicolons.
362    pub fn parse_arguments<I, S>(&self, arguments: I) -> Result<ParsedCommands, CommandParseError>
363    where
364        I: IntoIterator<Item = S>,
365        S: AsRef<str>,
366    {
367        let mut commands = ParsedCommands::with_grouping(CommandGrouping::ByLine);
368        let mut current = Vec::new();
369
370        for argument in arguments {
371            let mut value = argument.as_ref().to_owned();
372            let mut ends_command = false;
373
374            if value.ends_with(';') {
375                value.pop();
376                if value.ends_with('\\') {
377                    value.pop();
378                    value.push(';');
379                } else {
380                    ends_command = true;
381                }
382            }
383
384            if !ends_command || !value.is_empty() {
385                current.push(CommandArgument::String(value));
386            }
387            if ends_command && !current.is_empty() {
388                commands.push_command(command_from_arguments(std::mem::take(&mut current), 1)?);
389            }
390        }
391
392        if !current.is_empty() {
393            commands.push_command(command_from_arguments(current, 1)?);
394        }
395
396        self.expand_and_lookup(commands, false)
397    }
398
399    fn parse_inner(
400        &self,
401        input: &str,
402        no_alias: bool,
403        grouping: CommandGrouping,
404    ) -> Result<ParsedCommands, CommandParseError> {
405        let mut parser = GrammarParser::new(Lexer::new(input, self), grouping);
406        let commands = parser.parse_all()?;
407        self.expand_and_lookup(commands, no_alias)
408    }
409
410    fn expand_and_lookup(
411        &self,
412        commands: ParsedCommands,
413        no_alias: bool,
414    ) -> Result<ParsedCommands, CommandParseError> {
415        let assignments = commands.assignments.clone();
416        let mut output = ParsedCommands {
417            commands: Vec::new(),
418            assignments: commands.assignments,
419            grouping: commands.grouping,
420        };
421
422        for mut command in commands.commands {
423            if !no_alias {
424                if let Some(alias) = self.find_command_alias(&command.name) {
425                    let mut alias_parser = self.clone();
426                    alias_parser.environment.extend(
427                        assignments
428                            .iter()
429                            .map(|assignment| (assignment.name.clone(), assignment.value.clone())),
430                    );
431                    let mut replacement =
432                        alias_parser.parse_inner(alias, true, CommandGrouping::OneGroup)?;
433                    for replacement_command in &mut replacement.commands {
434                        replacement_command.line = command.line;
435                    }
436                    if let Some(last) = replacement.commands.last_mut() {
437                        last.arguments.append(&mut command.arguments);
438                    }
439                    output.append(replacement);
440                    continue;
441                }
442            }
443
444            for argument in &mut command.arguments {
445                if let CommandArgument::Commands(nested) = argument {
446                    let nested_commands = std::mem::take(nested);
447                    *nested = self.expand_and_lookup(nested_commands, no_alias)?;
448                }
449            }
450
451            let entry = lookup_command_at(&command.name, command.line)?;
452            command.name = entry.name.to_owned();
453            output.push_command(command);
454        }
455
456        Ok(output)
457    }
458
459    fn find_command_alias(&self, name: &str) -> Option<&str> {
460        self.command_aliases
461            .iter()
462            .rev()
463            .find(|alias| alias.name() == name)
464            .map(CommandAlias::value)
465    }
466
467    fn lookup_environment(&self, name: &str) -> Option<&str> {
468        self.environment
469            .iter()
470            .rev()
471            .find(|(candidate, _)| candidate == name)
472            .map(|(_, value)| value.as_str())
473    }
474
475    fn expand_tilde(&self, user: &str) -> Option<&str> {
476        if user.is_empty() {
477            return self
478                .lookup_environment("HOME")
479                .filter(|home| !home.is_empty())
480                .or(self.home_dir.as_deref());
481        }
482
483        self.user_home_dirs
484            .iter()
485            .find(|(candidate, _)| candidate == user)
486            .map(|(_, home)| home.as_str())
487    }
488
489    fn condition_is_true(&self, value: &str) -> bool {
490        let expanded = if value.contains("#{") {
491            render_template(
492                value,
493                &ParseTimeFormatVariables {
494                    values: &self.format_variables,
495                },
496            )
497        } else {
498            value.to_owned()
499        };
500
501        is_truthy(&expanded)
502    }
503}
504
505struct ParseTimeFormatVariables<'a> {
506    values: &'a [(String, String)],
507}
508
509impl FormatVariables for ParseTimeFormatVariables<'_> {
510    fn format_value(&self, _variable: FormatVariable) -> Option<String> {
511        None
512    }
513
514    fn format_value_by_name(&self, name: &str) -> Option<String> {
515        self.values
516            .iter()
517            .rev()
518            .find(|(candidate, _)| candidate == name)
519            .map(|(_, value)| value.clone())
520    }
521}
522
523/// Error returned by command tokenization, parsing, or lookup.
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub struct CommandParseError {
526    line: usize,
527    message: String,
528}
529
530impl CommandParseError {
531    /// Returns the one-based input line for the error, or zero when unknown.
532    #[must_use]
533    pub fn line(&self) -> usize {
534        self.line
535    }
536
537    /// Returns the tmux-style error message.
538    #[must_use]
539    pub fn message(&self) -> &str {
540        &self.message
541    }
542
543    pub(crate) fn new(line: usize, message: impl Into<String>) -> Self {
544        Self {
545            line,
546            message: message.into(),
547        }
548    }
549}
550
551impl fmt::Display for CommandParseError {
552    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
553        formatter.write_str(&self.message)
554    }
555}
556
557impl Error for CommandParseError {}
558
559fn command_from_arguments(
560    mut arguments: Vec<CommandArgument>,
561    line: usize,
562) -> Result<ParsedCommand, CommandParseError> {
563    let Some(CommandArgument::String(name)) = arguments.first() else {
564        return Err(CommandParseError::new(line, "no command"));
565    };
566    let name = name.clone();
567    arguments.remove(0);
568    Ok(ParsedCommand::new(name, arguments, line))
569}
570
571fn escape_argument(value: &str) -> String {
572    if value.is_empty() {
573        return "''".to_owned();
574    }
575    if !value
576        .chars()
577        .any(|ch| ch.is_whitespace() || matches!(ch, ';' | '{' | '}' | '\'' | '"' | '#'))
578    {
579        return value.to_owned();
580    }
581
582    format!("'{}'", value.replace('\'', "'\\''"))
583}
584
585#[cfg(test)]
586#[path = "command_parser/tests.rs"]
587mod tests;