Skip to main content

slash_lang/parser/
mod.rs

1use crate::parser::ast::{Command, Op, Pipeline, Program, Redirection};
2use crate::parser::chain::parse_builder_chain;
3use crate::parser::errors::ParseError;
4use crate::parser::lexer::{lex, Token};
5use crate::parser::normalize::normalize_name;
6use crate::parser::normalize::parse_test_id;
7use crate::parser::priority::infer_priority;
8use crate::parser::suffixes::{strip_optional, strip_urgency, strip_verbosity};
9
10/// Parse a slash-command string into a [`Program`] AST.
11///
12/// # Grammar (informally)
13///
14/// ```text
15/// program  := pipeline ( ( '&&' | '||' ) pipeline )*
16/// pipeline := command  ( ( '|'  | '|&' ) command  )* redirect?
17/// redirect := ( '>' | '>>' ) filename
18/// command  := SLASH_TOKEN arg*
19/// ```
20///
21/// Tokenization splits on ASCII whitespace only — no quoting, no escaping.
22///
23/// # Priority inference
24///
25/// Priority is inferred from the **raw** command token's casing before normalization:
26///
27/// | Shape          | Priority |
28/// |----------------|----------|
29/// | `ALL_CAPS`     | Max      |
30/// | `TitleCase`    | High     |
31/// | `camelCase`    | Medium   |
32/// | `kebab-case`   | Low      |
33/// | `snake_case`   | Lowest   |
34///
35/// # Errors
36///
37/// Returns [`ParseError`] on any structural violation, including:
38/// - Two commands in sequence without an operator
39/// - Standalone `!`/`!!`/`!!!` urgency tokens
40/// - More than three `!` markers on a command
41/// - Double `??` optional suffix
42/// - Malformed builder chain (unmatched parentheses)
43/// - Redirection followed by a non-`&&`/`||` token
44/// - Digits in command names outside the `/test`-family
45///
46/// # Examples
47///
48/// ```
49/// use slash_lang::parser::parse;
50/// use slash_lang::parser::ast::{Priority, Urgency};
51///
52/// let program = parse("/Build.target(release)! | /test").unwrap();
53/// let cmd = &program.pipelines[0].commands[0];
54/// assert_eq!(cmd.name, "build");
55/// assert_eq!(cmd.priority, Priority::High);
56/// assert_eq!(cmd.urgency, Urgency::Low);
57/// assert_eq!(cmd.args[0].name, "target");
58/// assert_eq!(cmd.args[0].value.as_deref(), Some("release"));
59/// ```
60#[must_use = "parsing produces a Program AST; ignoring it is likely a bug"]
61pub fn parse(input: &str) -> Result<Program, ParseError> {
62    let tokens = lex(input)?;
63    let mut pipelines: Vec<Pipeline> = Vec::new();
64    let mut commands: Vec<Command> = Vec::new();
65    let mut after_redirection = false;
66
67    let mut i = 0;
68    while i < tokens.len() {
69        let token = &tokens[i];
70
71        if after_redirection && !matches!(token, Token::And | Token::Or) {
72            return Err(ParseError::InvalidRedirection {
73                token: token_str(token),
74                position: i,
75            });
76        }
77
78        match token {
79            Token::And | Token::Or => {
80                if commands.is_empty() {
81                    return Err(ParseError::MissingOperator {
82                        token: token_str(token),
83                        position: i,
84                    });
85                }
86                let op = match token {
87                    Token::And => Op::And,
88                    Token::Or => Op::Or,
89                    _ => unreachable!(),
90                };
91                pipelines.push(Pipeline {
92                    commands,
93                    operator: Some(op),
94                });
95                commands = Vec::new();
96                after_redirection = false;
97                i += 1;
98            }
99
100            Token::Pipe | Token::PipeErr => {
101                if commands.is_empty() {
102                    return Err(ParseError::MissingOperator {
103                        token: token_str(token),
104                        position: i,
105                    });
106                }
107                let op = match token {
108                    Token::Pipe => Op::Pipe,
109                    Token::PipeErr => Op::PipeErr,
110                    _ => unreachable!(),
111                };
112                if let Some(last) = commands.last_mut() {
113                    if last.pipe.is_some() {
114                        return Err(ParseError::MissingOperator {
115                            token: token_str(token),
116                            position: i,
117                        });
118                    }
119                    last.pipe = Some(op);
120                }
121                i += 1;
122            }
123
124            Token::Redirect | Token::Append => {
125                if commands.is_empty() {
126                    return Err(ParseError::InvalidRedirection {
127                        token: token_str(token),
128                        position: i,
129                    });
130                }
131                if i + 1 >= tokens.len() {
132                    return Err(ParseError::InvalidRedirection {
133                        token: token_str(token),
134                        position: i,
135                    });
136                }
137                let target_token = &tokens[i + 1];
138                if matches!(
139                    target_token,
140                    Token::And
141                        | Token::Or
142                        | Token::Pipe
143                        | Token::PipeErr
144                        | Token::Redirect
145                        | Token::Append
146                ) {
147                    return Err(ParseError::InvalidRedirection {
148                        token: token_str(token),
149                        position: i,
150                    });
151                }
152                let target = match target_token {
153                    Token::Word(w) => w.clone(),
154                    Token::Command(c) => c.clone(),
155                    _ => {
156                        return Err(ParseError::InvalidRedirection {
157                            token: token_str(token),
158                            position: i,
159                        })
160                    }
161                };
162                let redirect = if matches!(token, Token::Redirect) {
163                    Redirection::Truncate(target)
164                } else {
165                    Redirection::Append(target)
166                };
167                if let Some(last) = commands.last_mut() {
168                    last.redirect = Some(redirect);
169                }
170                after_redirection = true;
171                i += 2;
172            }
173
174            Token::Word(w) => {
175                return Err(if w.starts_with('!') {
176                    ParseError::InvalidBang {
177                        token: w.clone(),
178                        position: i,
179                    }
180                } else {
181                    ParseError::MissingOperator {
182                        token: w.clone(),
183                        position: i,
184                    }
185                });
186            }
187
188            Token::Command(raw) => {
189                if let Some(last) = commands.last() {
190                    if last.pipe.is_none() {
191                        return Err(ParseError::MissingOperator {
192                            token: raw.clone(),
193                            position: i,
194                        });
195                    }
196                }
197
198                // Strip suffixes outer → inner: urgency(!), verbosity(+/-), optional(?)
199                let (after_urgency, urgency) =
200                    strip_urgency(raw).ok_or_else(|| ParseError::InvalidBang {
201                        token: raw.clone(),
202                        position: i,
203                    })?;
204                let (after_verbosity, verbosity) = strip_verbosity(after_urgency);
205                let (bare_token, optional) =
206                    strip_optional(after_verbosity).map_err(|_| ParseError::InvalidSuffix {
207                        token: raw.clone(),
208                        position: i,
209                    })?;
210
211                // Split command name, primary arg, and builder chain.
212                let parts =
213                    parse_builder_chain(bare_token).map_err(|_| ParseError::MalformedChain {
214                        token: raw.clone(),
215                        position: i,
216                    })?;
217                let (cmd_raw, args) = (parts.name, parts.args);
218
219                if cmd_raw.chars().any(|c| c.is_ascii_digit()) && parse_test_id(&cmd_raw).is_none()
220                {
221                    return Err(ParseError::InvalidDigits {
222                        token: raw.clone(),
223                        position: i,
224                    });
225                }
226
227                let name = normalize_name(&cmd_raw);
228                let test_id = parse_test_id(&cmd_raw);
229                let mut cmd = Command::new(name, infer_priority(&cmd_raw));
230                cmd.raw = raw.to_string();
231                cmd.urgency = urgency;
232                cmd.verbosity = verbosity;
233                cmd.optional = optional;
234                cmd.primary = parts.primary;
235                cmd.args = args;
236                cmd.test_id = test_id;
237                commands.push(cmd);
238                after_redirection = false;
239                i += 1;
240            }
241        }
242    }
243
244    if !commands.is_empty() {
245        pipelines.push(Pipeline {
246            commands,
247            operator: None,
248        });
249    }
250
251    Ok(Program { pipelines })
252}
253
254pub mod ast;
255pub mod chain;
256pub mod errors;
257pub mod lexer;
258pub mod normalize;
259pub mod priority;
260pub mod suffixes;
261
262fn token_str(token: &Token) -> String {
263    match token {
264        Token::Command(s) | Token::Word(s) => s.clone(),
265        Token::Pipe => "|".to_string(),
266        Token::PipeErr => "|&".to_string(),
267        Token::And => "&&".to_string(),
268        Token::Or => "||".to_string(),
269        Token::Redirect => ">".to_string(),
270        Token::Append => ">>".to_string(),
271    }
272}