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