Skip to main content

with_watch/
parser.rs

1use crate::error::{Result, WithWatchError};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct ParsedShellExpression {
5    pub expression: String,
6    pub commands: Vec<ShellCommand>,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
10pub struct ShellCommand {
11    pub env_assignments: Vec<ShellEnvAssignment>,
12    pub argv: Vec<String>,
13    pub redirects: Vec<ShellRedirect>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ShellEnvAssignment {
18    pub key: String,
19    pub value: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ShellRedirect {
24    pub operator: ShellRedirectOperator,
25    pub target: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ShellRedirectOperator {
30    Read,
31    ReadWrite,
32    Write,
33    Append,
34    WriteAll,
35    AppendAll,
36    Clobber,
37    Other(String),
38}
39
40impl ShellRedirectOperator {
41    pub fn as_str(&self) -> &str {
42        match self {
43            Self::Read => "<",
44            Self::ReadWrite => "<>",
45            Self::Write => ">",
46            Self::Append => ">>",
47            Self::WriteAll => "&>",
48            Self::AppendAll => "&>>",
49            Self::Clobber => ">|",
50            Self::Other(operator) => operator.as_str(),
51        }
52    }
53
54    pub fn reads_input(&self) -> bool {
55        matches!(self, Self::Read | Self::ReadWrite)
56    }
57
58    pub fn writes_output(&self) -> bool {
59        matches!(
60            self,
61            Self::Write | Self::Append | Self::WriteAll | Self::AppendAll | Self::Clobber
62        )
63    }
64}
65
66pub fn parse_shell_expression(expression: &str) -> Result<ParsedShellExpression> {
67    let parsed = starbase_args::parse(expression).map_err(|error| WithWatchError::ShellParse {
68        message: error.to_string(),
69    })?;
70
71    let mut commands = Vec::new();
72    for pipeline in parsed.0 {
73        collect_pipeline_commands(pipeline, &mut commands)?;
74    }
75
76    Ok(ParsedShellExpression {
77        expression: expression.to_string(),
78        commands,
79    })
80}
81
82fn collect_pipeline_commands(
83    pipeline: starbase_args::Pipeline,
84    commands: &mut Vec<ShellCommand>,
85) -> Result<()> {
86    match pipeline {
87        starbase_args::Pipeline::Start(command_list)
88        | starbase_args::Pipeline::StartNegated(command_list)
89        | starbase_args::Pipeline::Pipe(command_list)
90        | starbase_args::Pipeline::PipeAll(command_list)
91        | starbase_args::Pipeline::PipeWith(command_list, _) => {
92            collect_command_list_commands(command_list, commands)
93        }
94    }
95}
96
97fn collect_command_list_commands(
98    command_list: starbase_args::CommandList,
99    commands: &mut Vec<ShellCommand>,
100) -> Result<()> {
101    let mut current_command_index: Option<usize> = None;
102
103    for sequence in command_list.0 {
104        match sequence {
105            starbase_args::Sequence::Start(command)
106            | starbase_args::Sequence::Then(command)
107            | starbase_args::Sequence::AndThen(command)
108            | starbase_args::Sequence::OrElse(command)
109            | starbase_args::Sequence::Passthrough(command) => {
110                let shell_command = build_shell_command(command)?;
111                if !shell_command.argv.is_empty() {
112                    commands.push(shell_command);
113                    current_command_index = Some(commands.len() - 1);
114                }
115            }
116            starbase_args::Sequence::Redirect(command, operator) => {
117                if let Some(index) = current_command_index {
118                    collect_redirects(command, operator, &mut commands[index].redirects);
119                }
120            }
121            starbase_args::Sequence::Stop(_) => {}
122        }
123    }
124
125    Ok(())
126}
127
128fn build_shell_command(command: starbase_args::Command) -> Result<ShellCommand> {
129    let mut shell_command = ShellCommand::default();
130
131    for argument in command.0 {
132        match argument {
133            starbase_args::Argument::EnvVar(key, value, _) => {
134                shell_command.env_assignments.push(ShellEnvAssignment {
135                    key,
136                    value: value.as_str().to_string(),
137                });
138            }
139            starbase_args::Argument::FlagGroup(flag) | starbase_args::Argument::Flag(flag) => {
140                shell_command.argv.push(flag);
141            }
142            starbase_args::Argument::Option(option, Some(value)) => {
143                shell_command.argv.push(option);
144                shell_command.argv.push(value.as_str().to_string());
145            }
146            starbase_args::Argument::Option(option, None) => {
147                shell_command.argv.push(option);
148            }
149            starbase_args::Argument::Value(value) => {
150                if shell_command.argv.is_empty() {
151                    validate_command_name(value.as_str())?;
152                }
153                shell_command.argv.push(value.as_str().to_string());
154            }
155        }
156    }
157
158    Ok(shell_command)
159}
160
161fn collect_redirects(
162    command: starbase_args::Command,
163    operator: String,
164    redirects: &mut Vec<ShellRedirect>,
165) {
166    for argument in command.0 {
167        match argument {
168            starbase_args::Argument::Option(_, _)
169            | starbase_args::Argument::Flag(_)
170            | starbase_args::Argument::FlagGroup(_)
171            | starbase_args::Argument::EnvVar(_, _, _) => {}
172            starbase_args::Argument::Value(value) => redirects.push(ShellRedirect {
173                operator: classify_redirect_operator(&operator),
174                target: value.as_str().to_string(),
175            }),
176        }
177    }
178}
179
180fn classify_redirect_operator(operator: &str) -> ShellRedirectOperator {
181    match operator {
182        "<" => ShellRedirectOperator::Read,
183        "<>" => ShellRedirectOperator::ReadWrite,
184        ">" => ShellRedirectOperator::Write,
185        ">>" => ShellRedirectOperator::Append,
186        "&>" | "1&>" | "2&>" => ShellRedirectOperator::WriteAll,
187        "&>>" | "1&>>" | "2&>>" => ShellRedirectOperator::AppendAll,
188        ">|" => ShellRedirectOperator::Clobber,
189        other => ShellRedirectOperator::Other(other.to_string()),
190    }
191}
192
193fn validate_command_name(command_name: &str) -> Result<()> {
194    let lowered = command_name.trim().to_ascii_lowercase();
195    let unsupported = matches!(
196        lowered.as_str(),
197        "if" | "then"
198            | "else"
199            | "elif"
200            | "fi"
201            | "for"
202            | "while"
203            | "until"
204            | "do"
205            | "done"
206            | "case"
207            | "esac"
208            | "function"
209            | "{"
210            | "}"
211    );
212
213    if unsupported {
214        return Err(WithWatchError::UnsupportedShellConstruct {
215            construct: command_name.to_string(),
216        });
217    }
218
219    Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{parse_shell_expression, ShellRedirectOperator};
225
226    #[test]
227    fn parses_command_lines_with_and_or_and_pipeline_operators() {
228        let parsed = parse_shell_expression("cp src.txt dest.txt && cat dest.txt | grep hello")
229            .expect("parse shell");
230
231        assert_eq!(parsed.commands.len(), 3);
232        assert_eq!(parsed.commands[0].argv, vec!["cp", "src.txt", "dest.txt"]);
233        assert_eq!(parsed.commands[1].argv, vec!["cat", "dest.txt"]);
234        assert_eq!(parsed.commands[2].argv, vec!["grep", "hello"]);
235    }
236
237    #[test]
238    fn preserves_redirect_targets_as_structured_metadata() {
239        let parsed =
240            parse_shell_expression("grep hello < input.txt > output.txt").expect("parse shell");
241
242        assert_eq!(parsed.commands.len(), 1);
243        let command = &parsed.commands[0];
244        assert_eq!(command.argv, vec!["grep", "hello"]);
245        assert_eq!(command.redirects.len(), 2);
246        assert_eq!(command.redirects[0].operator, ShellRedirectOperator::Read);
247        assert_eq!(command.redirects[0].target, "input.txt");
248        assert_eq!(command.redirects[1].operator, ShellRedirectOperator::Write);
249        assert_eq!(command.redirects[1].target, "output.txt");
250    }
251
252    #[test]
253    fn preserves_shell_option_values_as_separate_tokens() {
254        let parsed = parse_shell_expression("grep -f patterns.txt file.txt").expect("parse shell");
255
256        assert_eq!(parsed.commands.len(), 1);
257        assert_eq!(
258            parsed.commands[0].argv,
259            vec!["grep", "-f", "patterns.txt", "file.txt"]
260        );
261    }
262
263    #[test]
264    fn rejects_shell_control_flow_keywords() {
265        let error =
266            parse_shell_expression("if true; then echo hi; fi").expect_err("expected error");
267        assert!(error
268            .to_string()
269            .contains("Shell control-flow is out of scope"));
270    }
271}