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