Skip to main content

rmux_core/
command_parser.rs

1//! tmux-compatible command tokenization and command-name lookup.
2//!
3//! This module mirrors the frozen tmux `cmd-parse.y` lexer boundary closely
4//! enough for RMUX command dispatch and config parsing to share one parser.
5//! Frozen source anchors: `/opt/rmux/reference/tmux` at commit
6//! `31d77e29b6c9fbb07d032018da78db3a8a38d979`, especially `cmd.c:121`
7//! (`cmd_table[]`) and `cmd-parse.y:1053`, `cmd-parse.y:1201`,
8//! `cmd-parse.y:1626` for argv parsing, continuation handling, and
9//! tokenization.
10
11use std::error::Error;
12use std::fmt;
13
14use crate::{
15    formats::{is_truthy, render_template, FormatVariable, FormatVariables},
16    EnvironmentStore,
17};
18
19#[path = "command_parser/aliases.rs"]
20mod aliases;
21#[path = "command_parser/grammar.rs"]
22mod grammar;
23#[path = "command_parser/lexer.rs"]
24mod lexer;
25#[path = "command_parser/lookup.rs"]
26mod lookup;
27#[path = "command_parser/table.rs"]
28mod table;
29
30use aliases::CommandAlias;
31use grammar::GrammarParser;
32use lexer::Lexer;
33use lookup::lookup_command_at;
34pub use table::{CommandEntry, COMMAND_TABLE};
35
36const DEFAULT_MAX_COMMAND_BYTES: usize = 16 * 1024;
37/// Maximum size of a single command parsed from `source-file` input.
38pub const SOURCE_FILE_MAX_COMMAND_BYTES: usize = 1024 * 1024;
39
40/// Parses a tmux command string with default expansion context.
41pub fn parse_command_string(input: &str) -> Result<ParsedCommands, CommandParseError> {
42    CommandParser::new().parse(input)
43}
44
45/// Parses a tmux command argument vector with default expansion context.
46pub fn parse_command_arguments<I, S>(arguments: I) -> Result<ParsedCommands, CommandParseError>
47where
48    I: IntoIterator<Item = S>,
49    S: AsRef<str>,
50{
51    CommandParser::new().parse_arguments(arguments)
52}
53
54/// Looks up a frozen tmux command using exact alias then unique name prefix.
55pub fn lookup_command(name: &str) -> Result<&'static CommandEntry, CommandParseError> {
56    lookup_command_at(name, 0, &[])
57}
58
59/// Parser output for one command list.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ParsedCommands {
62    commands: Vec<ParsedCommand>,
63    assignments: Vec<EnvironmentAssignment>,
64    grouping: CommandGrouping,
65}
66
67impl ParsedCommands {
68    fn with_grouping(grouping: CommandGrouping) -> Self {
69        Self {
70            commands: Vec::new(),
71            assignments: Vec::new(),
72            grouping,
73        }
74    }
75
76    /// Returns the parsed command sequence.
77    #[must_use]
78    pub fn commands(&self) -> &[ParsedCommand] {
79        &self.commands
80    }
81
82    /// Returns parse-time environment assignments.
83    #[must_use]
84    pub fn assignments(&self) -> &[EnvironmentAssignment] {
85        &self.assignments
86    }
87
88    /// Returns how queue group IDs should be assigned for this parsed list.
89    #[must_use]
90    pub const fn grouping(&self) -> CommandGrouping {
91        self.grouping
92    }
93
94    /// Consumes the list and returns only the command sequence.
95    #[must_use]
96    pub fn into_commands(self) -> Vec<ParsedCommand> {
97        self.commands
98    }
99
100    /// Returns whether the parser found no executable commands.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.commands.is_empty()
104    }
105
106    fn push_assignment(&mut self, assignment: EnvironmentAssignment) {
107        self.assignments.push(assignment);
108    }
109
110    fn push_command(&mut self, command: ParsedCommand) {
111        self.commands.push(command);
112    }
113
114    /// Appends another parsed command list, preserving the grouping mode of
115    /// this list.
116    pub fn append(&mut self, mut other: Self) {
117        self.assignments.append(&mut other.assignments);
118        self.commands.append(&mut other.commands);
119    }
120
121    /// Adds an offset to every source line recorded in this command list.
122    ///
123    /// Recovery parsers use this after parsing a suffix of a larger source
124    /// file so diagnostics and verbose output still reference original lines.
125    pub fn add_line_offset(&mut self, offset: usize) {
126        if offset == 0 {
127            return;
128        }
129        for command in &mut self.commands {
130            command.add_line_offset(offset);
131        }
132    }
133
134    /// Converts the parsed commands back to a tmux-style command string.
135    #[must_use]
136    pub fn to_tmux_string(&self) -> String {
137        self.commands
138            .iter()
139            .map(ParsedCommand::to_tmux_string)
140            .collect::<Vec<_>>()
141            .join(" ; ")
142    }
143
144    /// Converts the parsed commands to a command string suitable for
145    /// embedding in a `bind-key` line.
146    #[must_use]
147    pub fn to_tmux_binding_string(&self) -> String {
148        self.commands
149            .iter()
150            .map(ParsedCommand::to_tmux_string)
151            .collect::<Vec<_>>()
152            .join(" \\; ")
153    }
154}
155
156/// Queue grouping mode captured while parsing a tmux command list.
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158pub enum CommandGrouping {
159    /// Commands that start on the same source line share one queue group.
160    #[default]
161    ByLine,
162    /// All commands in the parsed list share one queue group.
163    OneGroup,
164}
165
166/// One parsed tmux command with a canonical command name.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ParsedCommand {
169    name: String,
170    arguments: Vec<CommandArgument>,
171    line: usize,
172}
173
174impl ParsedCommand {
175    /// Returns the canonical command name.
176    #[must_use]
177    pub fn name(&self) -> &str {
178        &self.name
179    }
180
181    /// Returns the parsed command arguments.
182    #[must_use]
183    pub fn arguments(&self) -> &[CommandArgument] {
184        &self.arguments
185    }
186
187    /// Returns the one-based input line where this command started.
188    #[must_use]
189    pub fn line(&self) -> usize {
190        self.line
191    }
192
193    fn new(name: String, arguments: Vec<CommandArgument>, line: usize) -> Self {
194        Self {
195            name,
196            arguments,
197            line,
198        }
199    }
200
201    fn add_line_offset(&mut self, offset: usize) {
202        self.line = self.line.saturating_add(offset);
203        for argument in &mut self.arguments {
204            if let CommandArgument::Commands(commands) = argument {
205                commands.add_line_offset(offset);
206            }
207        }
208    }
209
210    /// Converts this command back to a tmux-style command string.
211    #[must_use]
212    pub fn to_tmux_string(&self) -> String {
213        std::iter::once(self.name.clone())
214            .chain(self.arguments.iter().map(CommandArgument::to_tmux_string))
215            .collect::<Vec<_>>()
216            .join(" ")
217    }
218}
219
220/// A parsed command argument.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum CommandArgument {
223    /// A scalar string argument after tmux quote and expansion handling.
224    String(String),
225    /// A brace-delimited nested command list.
226    Commands(ParsedCommands),
227}
228
229impl CommandArgument {
230    /// Returns the string value when this is a scalar argument.
231    #[must_use]
232    pub fn as_string(&self) -> Option<&str> {
233        match self {
234            Self::String(value) => Some(value),
235            Self::Commands(_) => None,
236        }
237    }
238
239    /// Converts the argument to a string suitable for the legacy CLI bridge.
240    #[must_use]
241    pub fn to_tmux_string(&self) -> String {
242        match self {
243            Self::String(value) => escape_argument(value),
244            Self::Commands(commands) => format!("{{ {} }}", commands.to_tmux_string()),
245        }
246    }
247}
248
249/// A parse-time `name=value` environment assignment.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct EnvironmentAssignment {
252    name: String,
253    value: String,
254    hidden: bool,
255}
256
257impl EnvironmentAssignment {
258    /// Returns the assignment variable name.
259    #[must_use]
260    pub fn name(&self) -> &str {
261        &self.name
262    }
263
264    /// Returns the assignment value.
265    #[must_use]
266    pub fn value(&self) -> &str {
267        &self.value
268    }
269
270    /// Returns whether `%hidden` preceded this assignment.
271    #[must_use]
272    pub fn hidden(&self) -> bool {
273        self.hidden
274    }
275
276    fn from_equals(value: String, hidden: bool) -> Self {
277        let (name, value) = value
278            .split_once('=')
279            .expect("lexer only classifies assignments containing '='");
280        Self {
281            name: name.to_owned(),
282            value: value.to_owned(),
283            hidden,
284        }
285    }
286}
287
288/// A reusable parser with parse-time expansion context.
289#[derive(Debug, Clone)]
290pub struct CommandParser {
291    environment: Vec<(String, String)>,
292    format_variables: Vec<(String, String)>,
293    home_dir: Option<String>,
294    user_home_dirs: Vec<(String, String)>,
295    command_aliases: Vec<CommandAlias>,
296    exact_commands: &'static [CommandEntry],
297    max_command_bytes: usize,
298}
299
300impl Default for CommandParser {
301    fn default() -> Self {
302        Self {
303            environment: Vec::new(),
304            format_variables: Vec::new(),
305            home_dir: None,
306            user_home_dirs: Vec::new(),
307            command_aliases: Vec::new(),
308            exact_commands: &[],
309            max_command_bytes: DEFAULT_MAX_COMMAND_BYTES,
310        }
311    }
312}
313
314impl CommandParser {
315    /// Creates a parser with no environment, tilde, or user alias overrides.
316    #[must_use]
317    pub fn new() -> Self {
318        let mut parser = Self::default();
319        parser.command_aliases.extend(CommandAlias::builtin());
320        parser
321    }
322
323    /// Adds one variable to the parse-time environment expansion context.
324    #[must_use]
325    pub fn with_environment_value(
326        mut self,
327        name: impl Into<String>,
328        value: impl Into<String>,
329    ) -> Self {
330        self.environment.push((name.into(), value.into()));
331        self
332    }
333
334    /// Adds one parse-time format variable used by `%if` condition expansion.
335    #[must_use]
336    pub fn with_format_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
337        self.format_variables.push((name.into(), value.into()));
338        self
339    }
340
341    /// Copies global values from an RMUX environment store into the parser.
342    #[must_use]
343    pub fn with_environment_store(mut self, environment: &EnvironmentStore) -> Self {
344        self.environment.extend(
345            environment
346                .global_entries()
347                .map(|(name, value)| (name.to_owned(), value.to_owned())),
348        );
349        self
350    }
351
352    /// Adds the fallback home directory used for `~` expansion.
353    #[must_use]
354    pub fn with_home_dir(mut self, home_dir: impl Into<String>) -> Self {
355        self.home_dir = Some(home_dir.into());
356        self
357    }
358
359    /// Adds a deterministic `~user` expansion mapping.
360    #[must_use]
361    pub fn with_user_home_dir(
362        mut self,
363        user: impl Into<String>,
364        home_dir: impl Into<String>,
365    ) -> Self {
366        self.user_home_dirs.push((user.into(), home_dir.into()));
367        self
368    }
369
370    /// Adds one `command-alias` option entry of the form `name=value`.
371    pub fn with_command_alias(
372        mut self,
373        definition: impl Into<String>,
374    ) -> Result<Self, CommandParseError> {
375        let definition = definition.into();
376        let Some(alias) = CommandAlias::parse(definition) else {
377            return Err(CommandParseError::new(
378                0,
379                "command-alias entry must be name=value",
380            ));
381        };
382        self.command_aliases.push(alias);
383        Ok(self)
384    }
385
386    /// Replaces the parser alias table with valid `command-alias` entries.
387    #[must_use]
388    pub fn with_command_aliases<I, S>(mut self, definitions: I) -> Self
389    where
390        I: IntoIterator<Item = S>,
391        S: Into<String>,
392    {
393        self.command_aliases.clear();
394        self.command_aliases
395            .extend(definitions.into_iter().filter_map(CommandAlias::parse));
396        self
397    }
398
399    /// Adds exact-only command names to this parser.
400    ///
401    /// These entries are intentionally excluded from tmux-style prefix lookup.
402    /// Use this for client-side RMUX extensions; server-side `source-file`
403    /// parsing should keep the frozen tmux command table.
404    #[must_use]
405    pub fn with_exact_commands(mut self, commands: &'static [CommandEntry]) -> Self {
406        self.exact_commands = commands;
407        self
408    }
409
410    /// Overrides the maximum parsed command size.
411    #[must_use]
412    pub fn with_max_command_bytes(mut self, max_command_bytes: usize) -> Self {
413        self.max_command_bytes = max_command_bytes;
414        self
415    }
416
417    /// Parses a tmux command string through the tmux-style lexer.
418    pub fn parse(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
419        self.parse_inner(input, false, CommandGrouping::ByLine)
420    }
421
422    /// Parses command structure without command-name lookup or alias expansion.
423    ///
424    /// Source recovery uses this to find the command boundary around a lookup
425    /// error without corrupting multi-line brace blocks.
426    pub fn parse_structure(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
427        let mut parser = GrammarParser::new(Lexer::new(input, self), CommandGrouping::ByLine);
428        let commands = parser.parse_all()?;
429        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
430        Ok(commands)
431    }
432
433    /// Parses source-file/startup config text with tmux source-file comment
434    /// semantics.
435    ///
436    /// tmux treats any unquoted `#` outside condition directives as the start of
437    /// a comment, even when the next byte is `{`. Command strings parsed from
438    /// argv or option values keep RMUX's historical `#{...}` token support; only
439    /// source-file text uses this stricter mode.
440    pub fn parse_source_file(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
441        self.parse_source_file_inner(input, false, CommandGrouping::ByLine)
442    }
443
444    /// Parses source-file structure without command-name lookup or alias
445    /// expansion. Recovery uses this to locate command boundaries after a
446    /// lookup error while preserving source-file comment semantics.
447    pub fn parse_source_file_structure(
448        &self,
449        input: &str,
450    ) -> Result<ParsedCommands, CommandParseError> {
451        let mut parser =
452            GrammarParser::new(Lexer::new_source_file(input, self), CommandGrouping::ByLine);
453        let commands = parser.parse_all()?;
454        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
455        Ok(commands)
456    }
457
458    /// Parses a tmux command string with `CMD_PARSE_ONEGROUP` semantics.
459    ///
460    /// tmux uses this mode when a command string is parsed from an argument or
461    /// option value, so embedded newlines do not create independent abort
462    /// groups.
463    pub fn parse_one_group(&self, input: &str) -> Result<ParsedCommands, CommandParseError> {
464        self.parse_inner(input, false, CommandGrouping::OneGroup)
465    }
466
467    /// Parses an argv-style tmux command vector.
468    ///
469    /// tmux treats these arguments as already split and only divides commands
470    /// on unescaped trailing semicolons.
471    pub fn parse_arguments<I, S>(&self, arguments: I) -> Result<ParsedCommands, CommandParseError>
472    where
473        I: IntoIterator<Item = S>,
474        S: AsRef<str>,
475    {
476        let arguments = arguments
477            .into_iter()
478            .map(|argument| argument.as_ref().to_owned())
479            .collect::<Vec<_>>();
480        let command_bytes = arguments
481            .iter()
482            .map(String::len)
483            .sum::<usize>()
484            .saturating_add(arguments.len().saturating_sub(1));
485        ensure_command_length(command_bytes, 0, self.max_command_bytes)?;
486
487        let mut commands = ParsedCommands::with_grouping(CommandGrouping::ByLine);
488        let mut current = Vec::new();
489
490        for argument in arguments {
491            let mut value = argument;
492            let mut ends_command = false;
493
494            if value.ends_with(';') {
495                value.pop();
496                if value.ends_with('\\') {
497                    value.pop();
498                    value.push(';');
499                } else {
500                    ends_command = true;
501                }
502            }
503
504            if !ends_command || !value.is_empty() {
505                current.push(CommandArgument::String(value));
506            }
507            if ends_command && !current.is_empty() {
508                commands.push_command(command_from_arguments(std::mem::take(&mut current), 1)?);
509            }
510        }
511
512        if !current.is_empty() {
513            commands.push_command(command_from_arguments(current, 1)?);
514        }
515
516        self.expand_and_lookup(commands, false)
517    }
518
519    fn parse_inner(
520        &self,
521        input: &str,
522        no_alias: bool,
523        grouping: CommandGrouping,
524    ) -> Result<ParsedCommands, CommandParseError> {
525        let mut parser = GrammarParser::new(Lexer::new(input, self), grouping);
526        let commands = parser.parse_all()?;
527        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
528        self.expand_and_lookup(commands, no_alias)
529    }
530
531    fn parse_source_file_inner(
532        &self,
533        input: &str,
534        no_alias: bool,
535        grouping: CommandGrouping,
536    ) -> Result<ParsedCommands, CommandParseError> {
537        let mut parser = GrammarParser::new(Lexer::new_source_file(input, self), grouping);
538        let commands = parser.parse_all()?;
539        ensure_parsed_command_lengths(&commands, self.max_command_bytes)?;
540        self.expand_and_lookup(commands, no_alias)
541    }
542
543    fn expand_and_lookup(
544        &self,
545        commands: ParsedCommands,
546        no_alias: bool,
547    ) -> Result<ParsedCommands, CommandParseError> {
548        let assignments = commands.assignments.clone();
549        let mut output = ParsedCommands {
550            commands: Vec::new(),
551            assignments: commands.assignments,
552            grouping: commands.grouping,
553        };
554
555        for mut command in commands.commands {
556            if !no_alias {
557                if let Some(alias) = self.find_command_alias(&command.name) {
558                    let mut alias_parser = self.clone();
559                    alias_parser.environment.extend(
560                        assignments
561                            .iter()
562                            .map(|assignment| (assignment.name.clone(), assignment.value.clone())),
563                    );
564                    let mut replacement = alias_parser
565                        .parse_inner(alias, true, CommandGrouping::OneGroup)
566                        .map_err(|error| error.with_line(command.line))?;
567                    for replacement_command in &mut replacement.commands {
568                        replacement_command.line = command.line;
569                    }
570                    if let Some(last) = replacement.commands.last_mut() {
571                        last.arguments.append(&mut command.arguments);
572                    }
573                    output.append(replacement);
574                    continue;
575                }
576            }
577
578            for argument in &mut command.arguments {
579                if let CommandArgument::Commands(nested) = argument {
580                    let nested_commands = std::mem::take(nested);
581                    *nested = self.expand_and_lookup(nested_commands, no_alias)?;
582                }
583            }
584
585            let entry = lookup_command_at(&command.name, command.line, self.exact_commands)?;
586            command.name = entry.name.to_owned();
587            output.push_command(command);
588        }
589
590        Ok(output)
591    }
592
593    fn find_command_alias(&self, name: &str) -> Option<&str> {
594        self.command_aliases
595            .iter()
596            .rev()
597            .find(|alias| alias.name() == name)
598            .map(CommandAlias::value)
599    }
600
601    fn lookup_environment(&self, name: &str) -> Option<&str> {
602        self.environment
603            .iter()
604            .rev()
605            .find(|(candidate, _)| candidate == name)
606            .map(|(_, value)| value.as_str())
607    }
608
609    fn expand_tilde(&self, user: &str) -> Option<&str> {
610        if user.is_empty() {
611            return self
612                .lookup_environment("HOME")
613                .filter(|home| !home.is_empty())
614                .or(self.home_dir.as_deref());
615        }
616
617        self.user_home_dirs
618            .iter()
619            .find(|(candidate, _)| candidate == user)
620            .map(|(_, home)| home.as_str())
621    }
622
623    fn condition_is_true(&self, value: &str) -> bool {
624        let expanded = if value.contains("#{") {
625            render_template(
626                value,
627                &ParseTimeFormatVariables {
628                    values: &self.format_variables,
629                },
630            )
631        } else {
632            value.to_owned()
633        };
634
635        is_truthy(&expanded)
636    }
637}
638
639fn ensure_command_length(
640    bytes: usize,
641    line: usize,
642    max_command_bytes: usize,
643) -> Result<(), CommandParseError> {
644    if bytes > max_command_bytes {
645        return Err(CommandParseError::new(line, "command too long"));
646    }
647    Ok(())
648}
649
650fn ensure_parsed_command_lengths(
651    commands: &ParsedCommands,
652    max_command_bytes: usize,
653) -> Result<(), CommandParseError> {
654    for command in commands.commands() {
655        ensure_parsed_command_length(command, max_command_bytes)?;
656    }
657    Ok(())
658}
659
660fn ensure_parsed_command_length(
661    command: &ParsedCommand,
662    max_command_bytes: usize,
663) -> Result<(), CommandParseError> {
664    let mut bytes = command.name.len();
665    for argument in command.arguments() {
666        bytes = bytes.saturating_add(1);
667        match argument {
668            CommandArgument::String(value) => {
669                bytes = bytes.saturating_add(value.len());
670            }
671            CommandArgument::Commands(commands) => {
672                ensure_parsed_command_lengths(commands, max_command_bytes)?;
673            }
674        }
675    }
676    ensure_command_length(bytes, command.line(), max_command_bytes)
677}
678
679struct ParseTimeFormatVariables<'a> {
680    values: &'a [(String, String)],
681}
682
683impl FormatVariables for ParseTimeFormatVariables<'_> {
684    fn format_value(&self, _variable: FormatVariable) -> Option<String> {
685        None
686    }
687
688    fn format_value_by_name(&self, name: &str) -> Option<String> {
689        self.values
690            .iter()
691            .rev()
692            .find(|(candidate, _)| candidate == name)
693            .map(|(_, value)| value.clone())
694    }
695}
696
697/// Error returned by command tokenization, parsing, or lookup.
698#[derive(Debug, Clone, PartialEq, Eq)]
699pub struct CommandParseError {
700    line: usize,
701    message: String,
702    kind: CommandParseErrorKind,
703}
704
705/// Coarse parse error class used by source-file recovery.
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub enum CommandParseErrorKind {
708    /// The parser cannot safely identify a complete command boundary.
709    Structural,
710    /// Command name lookup failed after a structurally valid parse.
711    Lookup,
712    /// Tokenization or command-size validation failed.
713    Other,
714}
715
716impl CommandParseError {
717    /// Returns the one-based input line for the error, or zero when unknown.
718    #[must_use]
719    pub fn line(&self) -> usize {
720        self.line
721    }
722
723    /// Returns the tmux-style error message.
724    #[must_use]
725    pub fn message(&self) -> &str {
726        &self.message
727    }
728
729    /// Returns the coarse error class.
730    #[must_use]
731    pub const fn kind(&self) -> CommandParseErrorKind {
732        self.kind
733    }
734
735    pub(crate) fn new(line: usize, message: impl Into<String>) -> Self {
736        Self {
737            line,
738            message: message.into(),
739            kind: CommandParseErrorKind::Other,
740        }
741    }
742
743    pub(crate) fn structural(line: usize, message: impl Into<String>) -> Self {
744        Self {
745            line,
746            message: message.into(),
747            kind: CommandParseErrorKind::Structural,
748        }
749    }
750
751    pub(crate) fn lookup(line: usize, message: impl Into<String>) -> Self {
752        Self {
753            line,
754            message: message.into(),
755            kind: CommandParseErrorKind::Lookup,
756        }
757    }
758
759    fn with_line(mut self, line: usize) -> Self {
760        self.line = line;
761        self
762    }
763}
764
765impl fmt::Display for CommandParseError {
766    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
767        formatter.write_str(&self.message)
768    }
769}
770
771impl Error for CommandParseError {}
772
773fn command_from_arguments(
774    mut arguments: Vec<CommandArgument>,
775    line: usize,
776) -> Result<ParsedCommand, CommandParseError> {
777    let Some(CommandArgument::String(name)) = arguments.first() else {
778        return Err(CommandParseError::new(line, "no command"));
779    };
780    let name = name.clone();
781    arguments.remove(0);
782    Ok(ParsedCommand::new(name, arguments, line))
783}
784
785fn escape_argument(value: &str) -> String {
786    if value.is_empty() {
787        return "''".to_owned();
788    }
789    if !value.chars().any(argument_needs_quotes) {
790        return value.replace('\\', r"\\");
791    }
792
793    if value.contains('"') && !value.contains('\'') && !value.contains('$') {
794        return format!("'{value}'");
795    }
796
797    format!("\"{}\"", escape_double_quoted_argument(value))
798}
799
800fn argument_needs_quotes(ch: char) -> bool {
801    ch.is_whitespace() || matches!(ch, ';' | '{' | '}' | '\'' | '"' | '#' | '$')
802}
803
804fn escape_double_quoted_argument(value: &str) -> String {
805    let mut escaped = String::with_capacity(value.len());
806    for ch in value.chars() {
807        match ch {
808            '$' => escaped.push_str(r"\$"),
809            '\\' | '"' => {
810                escaped.push('\\');
811                escaped.push(ch);
812            }
813            _ => escaped.push(ch),
814        }
815    }
816    escaped
817}
818
819#[cfg(test)]
820#[path = "command_parser/tests.rs"]
821mod tests;