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/// Returns whether an argv token has the parse-time `name=value` form.
55///
56/// The identifier grammar is deliberately ASCII and matches tmux's command
57/// parser: a letter or underscore followed by letters, digits, or underscores.
58#[must_use]
59pub fn is_parse_time_assignment(argument: &str) -> bool {
60    let Some((name, _)) = argument.split_once('=') else {
61        return false;
62    };
63    let mut characters = name.chars();
64    let Some(first) = characters.next() else {
65        return false;
66    };
67    (first.is_ascii_alphabetic() || first == '_')
68        && characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
69}
70
71/// Looks up a frozen tmux command using exact alias then unique name prefix.
72pub fn lookup_command(name: &str) -> Result<&'static CommandEntry, CommandParseError> {
73    lookup_command_at(name, 0, &[])
74}
75
76/// Parser output for one command list.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct ParsedCommands {
79    commands: Vec<ParsedCommand>,
80    assignments: Vec<EnvironmentAssignment>,
81    grouping: CommandGrouping,
82}
83
84impl ParsedCommands {
85    fn with_grouping(grouping: CommandGrouping) -> Self {
86        Self {
87            commands: Vec::new(),
88            assignments: Vec::new(),
89            grouping,
90        }
91    }
92
93    /// Returns the parsed command sequence.
94    #[must_use]
95    pub fn commands(&self) -> &[ParsedCommand] {
96        &self.commands
97    }
98
99    /// Returns parse-time environment assignments.
100    #[must_use]
101    pub fn assignments(&self) -> &[EnvironmentAssignment] {
102        &self.assignments
103    }
104
105    /// Returns how queue group IDs should be assigned for this parsed list.
106    #[must_use]
107    pub const fn grouping(&self) -> CommandGrouping {
108        self.grouping
109    }
110
111    /// Consumes the list and returns only the command sequence.
112    #[must_use]
113    pub fn into_commands(self) -> Vec<ParsedCommand> {
114        self.commands
115    }
116
117    /// Returns whether the parser found no executable commands.
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.commands.is_empty()
121    }
122
123    fn push_assignment(&mut self, assignment: EnvironmentAssignment) {
124        self.assignments.push(assignment);
125    }
126
127    fn push_command(&mut self, mut command: ParsedCommand) {
128        command.drain_nested_assignments_into(&mut self.assignments);
129        self.commands.push(command);
130    }
131
132    /// Appends another parsed command list, preserving the grouping mode of
133    /// this list.
134    pub fn append(&mut self, mut other: Self) {
135        self.assignments.append(&mut other.assignments);
136        self.commands.append(&mut other.commands);
137    }
138
139    /// Adds an offset to every source line recorded in this command list.
140    ///
141    /// Recovery parsers use this after parsing a suffix of a larger source
142    /// file so diagnostics and verbose output still reference original lines.
143    pub fn add_line_offset(&mut self, offset: usize) {
144        if offset == 0 {
145            return;
146        }
147        for command in &mut self.commands {
148            command.add_line_offset(offset);
149        }
150    }
151
152    /// Converts the parsed commands back to a tmux-style command string.
153    #[must_use]
154    pub fn to_tmux_string(&self) -> String {
155        let mut rendered = String::new();
156        let mut previous_line = None;
157        for command in &self.commands {
158            if !rendered.is_empty() {
159                if previous_line.is_some_and(|line| line != command.line()) {
160                    rendered.push_str(" ;; ");
161                } else {
162                    rendered.push_str(" ; ");
163                }
164            }
165            rendered.push_str(&command.to_tmux_string());
166            previous_line = Some(command.line());
167        }
168        rendered
169    }
170
171    /// Converts the parsed commands to a command string suitable for
172    /// embedding in a `bind-key` line.
173    #[must_use]
174    pub fn to_tmux_binding_string(&self) -> String {
175        self.commands
176            .iter()
177            .map(ParsedCommand::to_tmux_reparse_string)
178            .collect::<Vec<_>>()
179            .join(" \\; ")
180    }
181
182    /// Converts the parsed commands to a lossless command string that can be
183    /// parsed again without applying display-only quote escaping twice.
184    #[must_use]
185    pub fn to_tmux_reparse_string(&self) -> String {
186        let mut rendered = self.assignments_to_tmux_reparse_string();
187        let mut previous_line = None;
188        for command in &self.commands {
189            if !rendered.is_empty() {
190                if previous_line.is_some_and(|line| line != command.line()) {
191                    rendered.push_str(" ;; ");
192                } else {
193                    rendered.push_str(" ; ");
194                }
195            }
196            rendered.push_str(&command.to_tmux_reparse_string());
197            previous_line = Some(command.line());
198        }
199        rendered
200    }
201
202    /// Converts only the parse-time assignments to a lossless command string.
203    #[must_use]
204    pub fn assignments_to_tmux_reparse_string(&self) -> String {
205        self.assignments
206            .iter()
207            .map(|assignment| {
208                let rendered = escape_argument_for_reparse(&format!(
209                    "{}={}",
210                    assignment.name(),
211                    assignment.value()
212                ));
213                if assignment.hidden() {
214                    format!("%hidden {rendered}")
215                } else {
216                    rendered
217                }
218            })
219            .collect::<Vec<_>>()
220            .join(" ; ")
221    }
222}
223
224/// Queue grouping mode captured while parsing a tmux command list.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub enum CommandGrouping {
227    /// Commands that start on the same source line share one queue group.
228    #[default]
229    ByLine,
230    /// All commands in the parsed list share one queue group.
231    OneGroup,
232}
233
234/// One parsed tmux command with a canonical command name.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ParsedCommand {
237    name: String,
238    arguments: Vec<CommandArgument>,
239    start_line: usize,
240    line: usize,
241}
242
243impl ParsedCommand {
244    /// Returns the canonical command name.
245    #[must_use]
246    pub fn name(&self) -> &str {
247        &self.name
248    }
249
250    /// Returns the parsed command arguments.
251    #[must_use]
252    pub fn arguments(&self) -> &[CommandArgument] {
253        &self.arguments
254    }
255
256    /// Returns this command with replacement arguments while preserving its
257    /// source location.
258    #[must_use]
259    pub fn with_arguments(mut self, arguments: Vec<CommandArgument>) -> Self {
260        self.arguments = arguments;
261        self
262    }
263
264    /// Returns the one-based input line where this command started.
265    #[must_use]
266    pub fn line(&self) -> usize {
267        self.line
268    }
269
270    fn new(name: String, arguments: Vec<CommandArgument>, line: usize) -> Self {
271        Self {
272            name,
273            arguments,
274            start_line: line,
275            line,
276        }
277    }
278
279    fn with_lines(
280        name: String,
281        arguments: Vec<CommandArgument>,
282        start_line: usize,
283        line: usize,
284    ) -> Self {
285        Self {
286            name,
287            arguments,
288            start_line,
289            line,
290        }
291    }
292
293    fn add_line_offset(&mut self, offset: usize) {
294        self.start_line = self.start_line.saturating_add(offset);
295        self.line = self.line.saturating_add(offset);
296        for argument in &mut self.arguments {
297            if let CommandArgument::Commands(commands) = argument {
298                commands.add_line_offset(offset);
299            }
300        }
301    }
302
303    fn drain_nested_assignments_into(&mut self, assignments: &mut Vec<EnvironmentAssignment>) {
304        for argument in &mut self.arguments {
305            if let CommandArgument::Commands(commands) = argument {
306                assignments.append(&mut commands.assignments);
307            }
308        }
309    }
310
311    /// Returns the first one-based input line occupied by this command.
312    #[must_use]
313    pub fn start_line(&self) -> usize {
314        self.start_line
315    }
316
317    /// Converts this command back to a tmux-style command string.
318    #[must_use]
319    pub fn to_tmux_string(&self) -> String {
320        std::iter::once(self.name.clone())
321            .chain(self.arguments.iter().map(CommandArgument::to_tmux_string))
322            .collect::<Vec<_>>()
323            .join(" ")
324    }
325
326    /// Converts this command to a lossless string for an internal
327    /// parse-execute bridge.
328    #[must_use]
329    pub fn to_tmux_reparse_string(&self) -> String {
330        std::iter::once(self.name.clone())
331            .chain(
332                self.arguments
333                    .iter()
334                    .map(CommandArgument::to_reparse_string),
335            )
336            .collect::<Vec<_>>()
337            .join(" ")
338    }
339}
340
341/// A parsed command argument.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum CommandArgument {
344    /// A scalar string argument after tmux quote and expansion handling.
345    String(String),
346    /// A brace-delimited nested command list.
347    Commands(ParsedCommands),
348}
349
350impl CommandArgument {
351    /// Returns the string value when this is a scalar argument.
352    #[must_use]
353    pub fn as_string(&self) -> Option<&str> {
354        match self {
355            Self::String(value) => Some(value),
356            Self::Commands(_) => None,
357        }
358    }
359
360    /// Converts the argument to a string suitable for the legacy CLI bridge.
361    #[must_use]
362    pub fn to_tmux_string(&self) -> String {
363        match self {
364            Self::String(value) => escape_argument(value),
365            Self::Commands(commands) => format!("{{ {} }}", commands.to_tmux_string()),
366        }
367    }
368
369    fn to_reparse_string(&self) -> String {
370        match self {
371            Self::String(value) => escape_argument_for_reparse(value),
372            Self::Commands(commands) => {
373                format!("{{ {} }}", commands.to_tmux_reparse_string())
374            }
375        }
376    }
377
378    /// Converts this argument to a lossless representation for an internal
379    /// parse-execute bridge.
380    #[must_use]
381    pub fn to_tmux_reparse_string(&self) -> String {
382        self.to_reparse_string()
383    }
384}
385
386/// A parse-time `name=value` environment assignment.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct EnvironmentAssignment {
389    name: String,
390    value: String,
391    hidden: bool,
392}
393
394impl EnvironmentAssignment {
395    /// Returns the assignment variable name.
396    #[must_use]
397    pub fn name(&self) -> &str {
398        &self.name
399    }
400
401    /// Returns the assignment value.
402    #[must_use]
403    pub fn value(&self) -> &str {
404        &self.value
405    }
406
407    /// Returns whether `%hidden` preceded this assignment.
408    #[must_use]
409    pub fn hidden(&self) -> bool {
410        self.hidden
411    }
412
413    fn from_equals(value: String, hidden: bool) -> Self {
414        let (name, value) = value
415            .split_once('=')
416            .expect("lexer only classifies assignments containing '='");
417        Self {
418            name: name.to_owned(),
419            value: value.to_owned(),
420            hidden,
421        }
422    }
423}
424
425/// A reusable parser with parse-time expansion context.
426#[derive(Debug, Clone)]
427pub struct CommandParser {
428    environment: Vec<(String, String)>,
429    format_variables: Vec<(String, String)>,
430    home_dir: Option<String>,
431    user_home_dirs: Vec<(String, String)>,
432    command_aliases: Vec<CommandAlias>,
433    exact_commands: &'static [CommandEntry],
434    max_command_bytes: usize,
435}
436
437impl Default for CommandParser {
438    fn default() -> Self {
439        Self {
440            environment: Vec::new(),
441            format_variables: Vec::new(),
442            home_dir: None,
443            user_home_dirs: Vec::new(),
444            command_aliases: Vec::new(),
445            exact_commands: &[],
446            max_command_bytes: DEFAULT_MAX_COMMAND_BYTES,
447        }
448    }
449}
450
451impl CommandParser {
452    /// Creates a parser with no environment, tilde, or user alias overrides.
453    #[must_use]
454    pub fn new() -> Self {
455        let mut parser = Self::default();
456        parser.command_aliases.extend(CommandAlias::builtin());
457        parser
458    }
459
460    /// Adds one variable to the parse-time environment expansion context.
461    #[must_use]
462    pub fn with_environment_value(
463        mut self,
464        name: impl Into<String>,
465        value: impl Into<String>,
466    ) -> Self {
467        self.environment.push((name.into(), value.into()));
468        self
469    }
470
471    /// Adds one parse-time format variable used by `%if` condition expansion.
472    #[must_use]
473    pub fn with_format_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
474        self.format_variables.push((name.into(), value.into()));
475        self
476    }
477
478    /// Copies global values from an RMUX environment store into the parser.
479    #[must_use]
480    pub fn with_environment_store(mut self, environment: &EnvironmentStore) -> Self {
481        self.environment.extend(
482            environment
483                .global_entries()
484                .map(|(name, value)| (name.to_owned(), value.to_owned())),
485        );
486        self
487    }
488
489    /// Adds the fallback home directory used for `~` expansion.
490    #[must_use]
491    pub fn with_home_dir(mut self, home_dir: impl Into<String>) -> Self {
492        self.home_dir = Some(home_dir.into());
493        self
494    }
495
496    /// Adds a deterministic `~user` expansion mapping.
497    #[must_use]
498    pub fn with_user_home_dir(
499        mut self,
500        user: impl Into<String>,
501        home_dir: impl Into<String>,
502    ) -> Self {
503        self.user_home_dirs.push((user.into(), home_dir.into()));
504        self
505    }
506
507    /// Adds one `command-alias` option entry of the form `name=value`.
508    pub fn with_command_alias(
509        mut self,
510        definition: impl Into<String>,
511    ) -> Result<Self, CommandParseError> {
512        let definition = definition.into();
513        let Some(alias) = CommandAlias::parse(definition) else {
514            return Err(CommandParseError::new(
515                0,
516                "command-alias entry must be name=value",
517            ));
518        };
519        self.command_aliases.push(alias);
520        Ok(self)
521    }
522
523    /// Replaces the parser alias table with valid `command-alias` entries.
524    #[must_use]
525    pub fn with_command_aliases<I, S>(mut self, definitions: I) -> Self
526    where
527        I: IntoIterator<Item = S>,
528        S: Into<String>,
529    {
530        self.command_aliases.clear();
531        self.command_aliases
532            .extend(definitions.into_iter().filter_map(CommandAlias::parse));
533        self
534    }
535
536    /// Adds exact-only command names to this parser.
537    ///
538    /// These entries are intentionally excluded from tmux-style prefix lookup.
539    /// Use this for client-side RMUX extensions; server-side `source-file`
540    /// parsing should keep the frozen tmux command table.
541    #[must_use]
542    pub fn with_exact_commands(mut self, commands: &'static [CommandEntry]) -> Self {
543        self.exact_commands = commands;
544        self
545    }
546
547    /// Overrides the maximum parsed command size.
548    #[must_use]
549    pub fn with_max_command_bytes(mut self, max_command_bytes: usize) -> Self {
550        self.max_command_bytes = max_command_bytes;
551        self
552    }
553
554    /// Parses a tmux command string through the tmux-style lexer.
555    pub fn parse(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
556        self.parse_inner(input, false, CommandGrouping::ByLine)
557    }
558
559    /// Parses command structure without command-name lookup or alias expansion.
560    ///
561    /// Source recovery uses this to find the command boundary around a lookup
562    /// error without corrupting multi-line brace blocks.
563    pub fn parse_structure(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
564        let mut parser = GrammarParser::new(Lexer::new(input, self), CommandGrouping::ByLine);
565        let commands = parser.parse_all()?;
566        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
567        Ok(commands)
568    }
569
570    /// Parses source-file/startup config text with tmux source-file comment
571    /// semantics.
572    ///
573    /// tmux treats any unquoted `#` outside condition directives as the start of
574    /// a comment, even when the next byte is `{`. Command strings parsed from
575    /// argv or option values keep RMUX's historical `#{...}` token support; only
576    /// source-file text uses this stricter mode.
577    pub fn parse_source_file(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
578        self.parse_source_file_inner(input, false, CommandGrouping::ByLine)
579    }
580
581    /// Parses source-file structure without command-name lookup or alias
582    /// expansion. Recovery uses this to locate command boundaries after a
583    /// lookup error while preserving source-file comment semantics.
584    pub fn parse_source_file_structure(
585        &self,
586        input: &str,
587    ) -> Result<ParsedCommands, CommandParseError> {
588        let mut parser = GrammarParser::new_source_file(
589            Lexer::new_source_file(input, self),
590            CommandGrouping::ByLine,
591        );
592        let commands = parser.parse_all()?;
593        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
594        Ok(commands)
595    }
596
597    /// Parses a tmux command string with `CMD_PARSE_ONEGROUP` semantics.
598    ///
599    /// tmux uses this mode when a command string is parsed from an argument or
600    /// option value, so embedded newlines do not create independent abort
601    /// groups.
602    pub fn parse_one_group(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
603        self.parse_inner(input, false, CommandGrouping::OneGroup)
604    }
605
606    /// Parses an argv-style tmux command vector.
607    ///
608    /// tmux treats these arguments as already split and only divides commands
609    /// on unescaped trailing semicolons.
610    pub fn parse_arguments<I, S>(&self, arguments: I) -> Result<ParsedCommands, CommandParseError>
611    where
612        I: IntoIterator<Item = S>,
613        S: AsRef<str>,
614    {
615        self.parse_arguments_inner(arguments, false)
616    }
617
618    /// Parses an argv-style command vector for RMUX's internal runtime bridge.
619    ///
620    /// Unlike [`Self::parse_arguments`], this recognizes one leading
621    /// `name=value` assignment in each command group. The direct argv parser
622    /// deliberately keeps tmux's behavior; only the server-owned alias bridge
623    /// opts into assignment classification.
624    pub fn parse_arguments_with_assignments<I, S>(
625        &self,
626        arguments: I,
627    ) -> Result<ParsedCommands, CommandParseError>
628    where
629        I: IntoIterator<Item = S>,
630        S: AsRef<str>,
631    {
632        self.parse_arguments_inner(arguments, true)
633    }
634
635    fn parse_arguments_inner<I, S>(
636        &self,
637        arguments: I,
638        classify_assignments: bool,
639    ) -> Result<ParsedCommands, CommandParseError>
640    where
641        I: IntoIterator<Item = S>,
642        S: AsRef<str>,
643    {
644        let arguments = arguments
645            .into_iter()
646            .map(|argument| argument.as_ref().to_owned())
647            .collect::<Vec<_>>();
648        let command_bytes = arguments
649            .iter()
650            .map(String::len)
651            .sum::<usize>()
652            .saturating_add(arguments.len().saturating_sub(1));
653        ensure_command_length(command_bytes, 0, self.max_command_bytes)?;
654
655        let mut commands = ParsedCommands::with_grouping(CommandGrouping::ByLine);
656        let mut current = Vec::new();
657        let mut group_has_assignment = false;
658
659        for argument in arguments {
660            let mut value = argument;
661            let mut ends_command = false;
662
663            if value.ends_with(';') {
664                value.pop();
665                if value.ends_with('\\') {
666                    value.pop();
667                    value.push(';');
668                } else {
669                    ends_command = true;
670                }
671            }
672
673            if !ends_command || !value.is_empty() {
674                if classify_assignments
675                    && current.is_empty()
676                    && !group_has_assignment
677                    && is_parse_time_assignment(&value)
678                {
679                    commands.push_assignment(EnvironmentAssignment::from_equals(value, false));
680                    group_has_assignment = true;
681                } else {
682                    current.push(CommandArgument::String(value));
683                }
684            }
685            if ends_command && !current.is_empty() {
686                commands.push_command(command_from_arguments(std::mem::take(&mut current), 1)?);
687            }
688            if ends_command {
689                group_has_assignment = false;
690            }
691        }
692
693        if !current.is_empty() {
694            commands.push_command(command_from_arguments(current, 1)?);
695        }
696
697        self.expand_and_lookup(commands, false)
698    }
699
700    fn parse_inner(
701        &self,
702        input: &str,
703        no_alias: bool,
704        grouping: CommandGrouping,
705    ) -> Result<ParsedCommands, CommandParseError> {
706        let mut parser = GrammarParser::new(Lexer::new(input, self), grouping);
707        let commands = parser.parse_all()?;
708        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
709        self.expand_and_lookup(commands, no_alias)
710    }
711
712    fn parse_source_file_inner(
713        &self,
714        input: &str,
715        no_alias: bool,
716        grouping: CommandGrouping,
717    ) -> Result<ParsedCommands, CommandParseError> {
718        let mut parser =
719            GrammarParser::new_source_file(Lexer::new_source_file(input, self), grouping);
720        let commands = parser.parse_all()?;
721        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
722        self.expand_and_lookup(commands, no_alias)
723    }
724
725    fn expand_and_lookup(
726        &self,
727        commands: ParsedCommands,
728        no_alias: bool,
729    ) -> Result<ParsedCommands, CommandParseError> {
730        let assignments = commands.assignments.clone();
731        let mut output = ParsedCommands {
732            commands: Vec::new(),
733            assignments: commands.assignments,
734            grouping: commands.grouping,
735        };
736
737        for mut command in commands.commands {
738            if !no_alias {
739                if let Some(alias) = self.find_command_alias(&command.name) {
740                    let mut alias_parser = self.clone();
741                    alias_parser.environment.extend(
742                        assignments
743                            .iter()
744                            .map(|assignment| (assignment.name.clone(), assignment.value.clone())),
745                    );
746                    let mut replacement = alias_parser
747                        .parse_inner(alias, true, CommandGrouping::OneGroup)
748                        .map_err(|error| error.with_line(command.line))?;
749                    for replacement_command in &mut replacement.commands {
750                        replacement_command.line = command.line;
751                        replacement_command.start_line = command.start_line;
752                    }
753                    if let Some(last) = replacement.commands.last_mut() {
754                        last.arguments.append(&mut command.arguments);
755                    }
756                    output.append(replacement);
757                    continue;
758                }
759            }
760
761            for argument in &mut command.arguments {
762                if let CommandArgument::Commands(nested) = argument {
763                    let nested_commands = std::mem::take(nested);
764                    *nested = self.expand_and_lookup(nested_commands, no_alias)?;
765                }
766            }
767
768            let entry = lookup_command_at(&command.name, command.line, self.exact_commands)?;
769            command.name = entry.name.to_owned();
770            output.push_command(command);
771        }
772
773        Ok(output)
774    }
775
776    fn find_command_alias(&self, name: &str) -> Option<&str> {
777        self.command_aliases
778            .iter()
779            .find(|alias| alias.name() == name)
780            .map(CommandAlias::value)
781    }
782
783    fn lookup_environment(&self, name: &str) -> Option<&str> {
784        self.environment
785            .iter()
786            .rev()
787            .find(|(candidate, _)| candidate == name)
788            .map(|(_, value)| value.as_str())
789    }
790
791    fn expand_tilde(&self, user: &str) -> Option<&str> {
792        if user.is_empty() {
793            return self
794                .lookup_environment("HOME")
795                .filter(|home| !home.is_empty())
796                .or(self.home_dir.as_deref());
797        }
798
799        self.user_home_dirs
800            .iter()
801            .find(|(candidate, _)| candidate == user)
802            .map(|(_, home)| home.as_str())
803    }
804
805    fn condition_is_true(&self, value: &str) -> bool {
806        let expanded = if value.contains("#{") {
807            render_template(
808                value,
809                &ParseTimeFormatVariables {
810                    values: &self.format_variables,
811                },
812            )
813        } else {
814            value.to_owned()
815        };
816
817        is_truthy(&expanded)
818    }
819}
820
821fn ensure_command_length(
822    bytes: usize,
823    line: usize,
824    max_command_bytes: usize,
825) -> Result<(), CommandParseError> {
826    if bytes > max_command_bytes {
827        return Err(CommandParseError::new(line, "command too long"));
828    }
829    Ok(())
830}
831
832fn ensure_parsed_command_lengths(
833    commands: &ParsedCommands,
834    max_command_bytes: usize,
835) -> Result<(), CommandParseError> {
836    for command in commands.commands() {
837        ensure_parsed_command_length(command, max_command_bytes)?;
838    }
839    Ok(())
840}
841
842fn ensure_parsed_command_length(
843    command: &ParsedCommand,
844    max_command_bytes: usize,
845) -> Result<(), CommandParseError> {
846    let mut bytes = command.name.len();
847    for argument in command.arguments() {
848        bytes = bytes.saturating_add(1);
849        match argument {
850            CommandArgument::String(value) => {
851                bytes = bytes.saturating_add(value.len());
852            }
853            CommandArgument::Commands(commands) => {
854                ensure_parsed_command_lengths(commands, max_command_bytes)?;
855            }
856        }
857    }
858    ensure_command_length(bytes, command.line(), max_command_bytes)
859}
860
861struct ParseTimeFormatVariables<'a> {
862    values: &'a [(String, String)],
863}
864
865impl FormatVariables for ParseTimeFormatVariables<'_> {
866    fn format_value(&self, _variable: FormatVariable) -> Option<String> {
867        None
868    }
869
870    fn format_value_by_name(&self, name: &str) -> Option<String> {
871        self.values
872            .iter()
873            .rev()
874            .find(|(candidate, _)| candidate == name)
875            .map(|(_, value)| value.clone())
876    }
877}
878
879/// Error returned by command tokenization, parsing, or lookup.
880#[derive(Debug, Clone, PartialEq, Eq)]
881pub struct CommandParseError {
882    line: usize,
883    message: String,
884    kind: CommandParseErrorKind,
885}
886
887/// Coarse parse error class used by source-file recovery.
888#[derive(Debug, Clone, Copy, PartialEq, Eq)]
889pub enum CommandParseErrorKind {
890    /// The parser cannot safely identify a complete command boundary.
891    Structural,
892    /// Command name lookup failed after a structurally valid parse.
893    Lookup,
894    /// Tokenization or command-size validation failed.
895    Other,
896}
897
898impl CommandParseError {
899    /// Returns the one-based input line for the error, or zero when unknown.
900    #[must_use]
901    pub fn line(&self) -> usize {
902        self.line
903    }
904
905    /// Returns the tmux-style error message.
906    #[must_use]
907    pub fn message(&self) -> &str {
908        &self.message
909    }
910
911    /// Returns the coarse error class.
912    #[must_use]
913    pub const fn kind(&self) -> CommandParseErrorKind {
914        self.kind
915    }
916
917    pub(crate) fn new(line: usize, message: impl Into<String>) -> Self {
918        Self {
919            line,
920            message: message.into(),
921            kind: CommandParseErrorKind::Other,
922        }
923    }
924
925    pub(crate) fn structural(line: usize, message: impl Into<String>) -> Self {
926        Self {
927            line,
928            message: message.into(),
929            kind: CommandParseErrorKind::Structural,
930        }
931    }
932
933    pub(crate) fn lookup(line: usize, message: impl Into<String>) -> Self {
934        Self {
935            line,
936            message: message.into(),
937            kind: CommandParseErrorKind::Lookup,
938        }
939    }
940
941    fn with_line(mut self, line: usize) -> Self {
942        self.line = line;
943        self
944    }
945}
946
947impl fmt::Display for CommandParseError {
948    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
949        formatter.write_str(&self.message)
950    }
951}
952
953impl Error for CommandParseError {}
954
955fn command_from_arguments(
956    mut arguments: Vec<CommandArgument>,
957    line: usize,
958) -> Result<ParsedCommand, CommandParseError> {
959    let Some(CommandArgument::String(name)) = arguments.first() else {
960        return Err(CommandParseError::new(line, "no command"));
961    };
962    let name = name.clone();
963    arguments.remove(0);
964    Ok(ParsedCommand::new(name, arguments, line))
965}
966
967pub(crate) fn escape_argument(value: &str) -> String {
968    if value.is_empty() {
969        return "''".to_owned();
970    }
971    if is_single_char_escaped_argument(value) {
972        return escape_unquoted_argument(value);
973    }
974    if !value.chars().any(argument_needs_double_quotes) {
975        if value.contains('"') {
976            return format!("'{}'", escape_single_quoted_argument(value));
977        }
978        return escape_unquoted_argument(value);
979    }
980
981    format!("\"{}\"", escape_double_quoted_argument(value))
982}
983
984fn escape_argument_for_reparse(value: &str) -> String {
985    let single_quoted_display =
986        value.contains('"') && !value.chars().any(argument_needs_double_quotes);
987    if single_quoted_display && value.chars().any(|ch| ch == '\\' || ch.is_ascii_control()) {
988        return format!("\"{}\"", escape_double_quoted_argument(value));
989    }
990    escape_argument(value)
991}
992
993fn is_single_char_escaped_argument(value: &str) -> bool {
994    let mut chars = value.chars();
995    let Some(ch) = chars.next() else {
996        return false;
997    };
998    chars.next().is_none() && matches!(ch, ';' | '{' | '}' | '\'' | '"' | '#' | '$' | '%')
999}
1000
1001fn argument_needs_double_quotes(ch: char) -> bool {
1002    matches!(ch, ' ' | ';' | '{' | '}' | '\'' | '#' | '$' | '%')
1003        || (ch.is_whitespace() && !matches!(ch, '\n' | '\r' | '\t'))
1004}
1005
1006fn escape_unquoted_argument(value: &str) -> String {
1007    let mut escaped = String::with_capacity(value.len());
1008    for (index, ch) in value.chars().enumerate() {
1009        match ch {
1010            '~' if index == 0 => escaped.push_str(r"\~"),
1011            ';' | '{' | '}' | '\'' | '"' | '#' | '$' | '%' => {
1012                escaped.push('\\');
1013                escaped.push(ch);
1014            }
1015            '\n' => escaped.push_str(r"\n"),
1016            '\r' => escaped.push_str(r"\r"),
1017            '\t' => escaped.push_str(r"\t"),
1018            '\\' => escaped.push_str(r"\\"),
1019            _ => escape_control_argument_char(&mut escaped, ch),
1020        }
1021    }
1022    escaped
1023}
1024
1025fn escape_single_quoted_argument(value: &str) -> String {
1026    let mut escaped = String::with_capacity(value.len());
1027    for ch in value.chars() {
1028        match ch {
1029            '\n' => escaped.push_str(r"\n"),
1030            '\r' => escaped.push_str(r"\r"),
1031            '\t' => escaped.push_str(r"\t"),
1032            '\\' => escaped.push_str(r"\\"),
1033            _ => escape_control_argument_char(&mut escaped, ch),
1034        }
1035    }
1036    escaped
1037}
1038
1039fn escape_double_quoted_argument(value: &str) -> String {
1040    let mut escaped = String::with_capacity(value.len());
1041    let mut chars = value.chars().enumerate().peekable();
1042    while let Some((index, ch)) = chars.next() {
1043        match ch {
1044            '~' if index == 0 => escaped.push_str(r"\~"),
1045            '\n' => escaped.push_str(r"\n"),
1046            '\r' => escaped.push_str(r"\r"),
1047            '\t' => escaped.push_str(r"\t"),
1048            '\u{7}' => escaped.push_str(r"\a"),
1049            '\u{8}' => escaped.push_str(r"\b"),
1050            '\u{b}' => escaped.push_str(r"\v"),
1051            '\u{c}' => escaped.push_str(r"\f"),
1052            '\u{1b}' => escaped.push_str(r"\033"),
1053            '$' if chars
1054                .peek()
1055                .is_some_and(|(_, next)| dollar_starts_variable(*next)) =>
1056            {
1057                escaped.push_str(r"\$")
1058            }
1059            '\\' | '"' => {
1060                escaped.push('\\');
1061                escaped.push(ch);
1062            }
1063            _ => escape_control_argument_char(&mut escaped, ch),
1064        }
1065    }
1066    escaped
1067}
1068
1069fn escape_control_argument_char(escaped: &mut String, ch: char) {
1070    match ch {
1071        '\u{7}' => escaped.push_str(r"\a"),
1072        '\u{8}' => escaped.push_str(r"\b"),
1073        '\u{b}' => escaped.push_str(r"\v"),
1074        '\u{c}' => escaped.push_str(r"\f"),
1075        '\u{1b}' => escaped.push_str(r"\033"),
1076        '\0'..='\u{1f}' | '\u{7f}' => {
1077            escaped.push('\\');
1078            escaped.push_str(&format!("{:03o}", ch as u32));
1079        }
1080        _ => escaped.push(ch),
1081    }
1082}
1083
1084fn dollar_starts_variable(ch: char) -> bool {
1085    ch == '{' || ch == '_' || ch.is_ascii_alphabetic()
1086}
1087
1088#[cfg(test)]
1089#[path = "command_parser/tests.rs"]
1090mod tests;