slash-lang 0.1.0

Parser and AST for the slash-command language
Documentation
use crate::parser::ast::{Command, Op, Pipeline, Program, Redirection};
use crate::parser::chain::parse_builder_chain;
use crate::parser::errors::ParseError;
use crate::parser::lexer::{lex, Token};
use crate::parser::normalize::normalize_name;
use crate::parser::normalize::parse_test_id;
use crate::parser::priority::infer_priority;
use crate::parser::suffixes::{strip_optional, strip_urgency, strip_verbosity};

/// Parse a slash-command string into a [`Program`] AST.
///
/// # Grammar (informally)
///
/// ```text
/// program  := pipeline ( ( '&&' | '||' ) pipeline )*
/// pipeline := command  ( ( '|'  | '|&' ) command  )* redirect?
/// redirect := ( '>' | '>>' ) filename
/// command  := SLASH_TOKEN arg*
/// ```
///
/// Tokenization splits on ASCII whitespace only — no quoting, no escaping.
///
/// # Priority inference
///
/// Priority is inferred from the **raw** command token's casing before normalization:
///
/// | Shape          | Priority |
/// |----------------|----------|
/// | `ALL_CAPS`     | Max      |
/// | `TitleCase`    | High     |
/// | `camelCase`    | Medium   |
/// | `kebab-case`   | Low      |
/// | `snake_case`   | Lowest   |
///
/// # Errors
///
/// Returns [`ParseError`] on any structural violation, including:
/// - Two commands in sequence without an operator
/// - Standalone `!`/`!!`/`!!!` urgency tokens
/// - More than three `!` markers on a command
/// - Double `??` optional suffix
/// - Malformed builder chain (unmatched parentheses)
/// - Redirection followed by a non-`&&`/`||` token
/// - Digits in command names outside the `/test`-family
///
/// # Examples
///
/// ```
/// use slash_lang::parser::parse;
/// use slash_lang::parser::ast::{Priority, Urgency};
///
/// let program = parse("/Build.target(release)! | /test").unwrap();
/// let cmd = &program.pipelines[0].commands[0];
/// assert_eq!(cmd.name, "build");
/// assert_eq!(cmd.priority, Priority::High);
/// assert_eq!(cmd.urgency, Urgency::Low);
/// assert_eq!(cmd.args[0].name, "target");
/// assert_eq!(cmd.args[0].value.as_deref(), Some("release"));
/// ```
#[must_use = "parsing produces a Program AST; ignoring it is likely a bug"]
pub fn parse(input: &str) -> Result<Program, ParseError> {
    let tokens = lex(input)?;
    let mut pipelines: Vec<Pipeline> = Vec::new();
    let mut commands: Vec<Command> = Vec::new();
    let mut after_redirection = false;

    let mut i = 0;
    while i < tokens.len() {
        let token = &tokens[i];

        if after_redirection && !matches!(token, Token::And | Token::Or) {
            return Err(ParseError::InvalidRedirection {
                token: token_str(token),
                position: i,
            });
        }

        match token {
            Token::And | Token::Or => {
                if commands.is_empty() {
                    return Err(ParseError::MissingOperator {
                        token: token_str(token),
                        position: i,
                    });
                }
                let op = match token {
                    Token::And => Op::And,
                    Token::Or => Op::Or,
                    _ => unreachable!(),
                };
                pipelines.push(Pipeline {
                    commands,
                    operator: Some(op),
                });
                commands = Vec::new();
                after_redirection = false;
                i += 1;
            }

            Token::Pipe | Token::PipeErr => {
                if commands.is_empty() {
                    return Err(ParseError::MissingOperator {
                        token: token_str(token),
                        position: i,
                    });
                }
                let op = match token {
                    Token::Pipe => Op::Pipe,
                    Token::PipeErr => Op::PipeErr,
                    _ => unreachable!(),
                };
                if let Some(last) = commands.last_mut() {
                    if last.pipe.is_some() {
                        return Err(ParseError::MissingOperator {
                            token: token_str(token),
                            position: i,
                        });
                    }
                    last.pipe = Some(op);
                }
                i += 1;
            }

            Token::Redirect | Token::Append => {
                if commands.is_empty() {
                    return Err(ParseError::InvalidRedirection {
                        token: token_str(token),
                        position: i,
                    });
                }
                if i + 1 >= tokens.len() {
                    return Err(ParseError::InvalidRedirection {
                        token: token_str(token),
                        position: i,
                    });
                }
                let target_token = &tokens[i + 1];
                if matches!(
                    target_token,
                    Token::And
                        | Token::Or
                        | Token::Pipe
                        | Token::PipeErr
                        | Token::Redirect
                        | Token::Append
                ) {
                    return Err(ParseError::InvalidRedirection {
                        token: token_str(token),
                        position: i,
                    });
                }
                let target = match target_token {
                    Token::Word(w) => w.clone(),
                    Token::Command(c) => c.clone(),
                    _ => {
                        return Err(ParseError::InvalidRedirection {
                            token: token_str(token),
                            position: i,
                        })
                    }
                };
                let redirect = if matches!(token, Token::Redirect) {
                    Redirection::Truncate(target)
                } else {
                    Redirection::Append(target)
                };
                if let Some(last) = commands.last_mut() {
                    last.redirect = Some(redirect);
                }
                after_redirection = true;
                i += 2;
            }

            Token::Word(w) => {
                return Err(if w.starts_with('!') {
                    ParseError::InvalidBang {
                        token: w.clone(),
                        position: i,
                    }
                } else {
                    ParseError::MissingOperator {
                        token: w.clone(),
                        position: i,
                    }
                });
            }

            Token::Command(raw) => {
                if let Some(last) = commands.last() {
                    if last.pipe.is_none() {
                        return Err(ParseError::MissingOperator {
                            token: raw.clone(),
                            position: i,
                        });
                    }
                }

                // Strip suffixes outer → inner: urgency(!), verbosity(+/-), optional(?)
                let (after_urgency, urgency) =
                    strip_urgency(raw).ok_or_else(|| ParseError::InvalidBang {
                        token: raw.clone(),
                        position: i,
                    })?;
                let (after_verbosity, verbosity) = strip_verbosity(after_urgency);
                let (bare_token, optional) =
                    strip_optional(after_verbosity).map_err(|_| ParseError::InvalidSuffix {
                        token: raw.clone(),
                        position: i,
                    })?;

                // Split command name, primary arg, and builder chain.
                let parts =
                    parse_builder_chain(bare_token).map_err(|_| ParseError::MalformedChain {
                        token: raw.clone(),
                        position: i,
                    })?;
                let (cmd_raw, args) = (parts.name, parts.args);

                if cmd_raw.chars().any(|c| c.is_ascii_digit()) && parse_test_id(&cmd_raw).is_none()
                {
                    return Err(ParseError::InvalidDigits {
                        token: raw.clone(),
                        position: i,
                    });
                }

                let name = normalize_name(&cmd_raw);
                let test_id = parse_test_id(&cmd_raw);
                let mut cmd = Command::new(name, infer_priority(&cmd_raw));
                cmd.raw = raw.to_string();
                cmd.urgency = urgency;
                cmd.verbosity = verbosity;
                cmd.optional = optional;
                cmd.primary = parts.primary;
                cmd.args = args;
                cmd.test_id = test_id;
                commands.push(cmd);
                after_redirection = false;
                i += 1;
            }
        }
    }

    if !commands.is_empty() {
        pipelines.push(Pipeline {
            commands,
            operator: None,
        });
    }

    Ok(Program { pipelines })
}

pub mod ast;
pub mod chain;
pub mod errors;
pub mod lexer;
pub mod normalize;
pub mod priority;
pub mod suffixes;

fn token_str(token: &Token) -> String {
    match token {
        Token::Command(s) | Token::Word(s) => s.clone(),
        Token::Pipe => "|".to_string(),
        Token::PipeErr => "|&".to_string(),
        Token::And => "&&".to_string(),
        Token::Or => "||".to_string(),
        Token::Redirect => ">".to_string(),
        Token::Append => ">>".to_string(),
    }
}