oxdock-parser 0.10.0-alpha

Parser and AST definitions for the OxDock DSL.
Documentation
use crate::ast::COMMANDS;
use anyhow::{Result, anyhow, bail};
use pest::{Parser, iterators::Pair};
use pest_derive::Parser;

#[derive(Parser)]
#[grammar = "dsl.pest"]
pub struct LanguageParser;

pub const LANGUAGE_SPEC: &str = include_str!("dsl.pest");

#[derive(Debug, Clone)]
pub enum RawToken<'a> {
    Guard {
        pair: Pair<'a, Rule>,
        line_end: usize,
    },
    BlockStart {
        line_no: usize,
    },
    BlockEnd {
        line_no: usize,
    },
    /// A structural command (WITH_IO, FOR, IF, LET) — parsed by the grammar.
    Command {
        pair: Pair<'a, Rule>,
        line_no: usize,
    },
    /// A generic instruction — command name + raw args, lowered by a function.
    Instruction {
        pair: Pair<'a, Rule>,
        line_no: usize,
    },
}

pub fn tokenize(input: &str) -> Result<Vec<RawToken<'_>>> {
    let mut tokens = Vec::new();
    let mut pairs = LanguageParser::parse(Rule::script, input)
        .map_err(|err| anyhow!(format_pest_error(err)))?;
    let Some(root) = pairs.next() else {
        return Ok(tokens);
    };

    for pair in root.into_inner() {
        let line_no = pair.as_span().start_pos().line_col().0;
        match pair.as_rule() {
            Rule::blank | Rule::hash_comment | Rule::semicolon | Rule::EOI | Rule::COMMENT => {}
            Rule::guard_line => {
                let (line_end, _) = pair.as_span().end_pos().line_col();
                tokens.push(RawToken::Guard { pair, line_end });
            }
            Rule::block_start => tokens.push(RawToken::BlockStart { line_no }),
            Rule::block_end => tokens.push(RawToken::BlockEnd { line_no }),
            // Structural commands — parsed by grammar-specific rules
            Rule::with_io_command
            | Rule::inherit_env_command
            | Rule::async_statement
            | Rule::async_statement_block
            | Rule::timeout_statement
            | Rule::let_async_statement
            | Rule::await_statement
            | Rule::cancel_statement
            | Rule::for_statement
            | Rule::let_statement
            | Rule::if_statement => tokens.push(RawToken::Command { pair, line_no }),
            // Generic instructions — lowered by a function
            Rule::instruction | Rule::instruction_inner => {
                tokens.push(RawToken::Instruction { pair, line_no })
            }
            other => bail!("unexpected parser rule {:?}", other),
        }
    }
    Ok(tokens)
}

fn format_pest_error(err: pest::error::Error<Rule>) -> String {
    use pest::error::{ErrorVariant, LineColLocation};

    let (line_no, col_start, col_end) = match err.line_col {
        LineColLocation::Pos((line, col)) => (line, col, col),
        LineColLocation::Span((line, col), (end_line, end_col)) => {
            if line == end_line {
                let end = end_col.saturating_sub(1).max(col);
                (line, col, end)
            } else {
                (line, col, col)
            }
        }
    };

    let mut msg = String::new();
    match &err.variant {
        ErrorVariant::ParsingError {
            positives,
            negatives,
        } => {
            msg.push_str("parse error");
            if !positives.is_empty() {
                let expected = positives
                    .iter()
                    .map(|r| format!("{:?}", r))
                    .collect::<Vec<_>>()
                    .join(", ");
                msg.push_str(&format!(" (expected: {expected})"));
            }
            if !negatives.is_empty() {
                let unexpected = negatives
                    .iter()
                    .map(|r| format!("{:?}", r))
                    .collect::<Vec<_>>()
                    .join(", ");
                msg.push_str(&format!(" (unexpected: {unexpected})"));
            }
        }
        ErrorVariant::CustomError { message } => {
            msg.push_str(message);
        }
    }

    let line = err.line();
    let caret_len = col_end.saturating_sub(col_start).saturating_add(1).max(1);
    let caret_pad = " ".repeat(col_start.saturating_sub(1));
    let caret_mark = "^".repeat(caret_len);

    if let Some(note) = detect_case_error(line) {
        msg.push_str(&format!("\nnote: {note}"));
    }

    msg.push_str(&format!(
        "\n  --> line {line_no}, col {col_start}-{col_end}\n  {line_no} | {line}\n    | {pad}{caret}",
        line_no = line_no,
        col_start = col_start,
        col_end = col_end,
        line = line,
        pad = caret_pad,
        caret = caret_mark
    ));

    msg
}

fn detect_case_error(line: &str) -> Option<String> {
    let trimmed = line.trim_start();
    if trimmed.is_empty() {
        return None;
    }
    if trimmed.starts_with("//") || trimmed.starts_with("/*") {
        return None;
    }
    if matches!(trimmed.chars().next(), Some('[' | '{' | '}' | ']')) {
        return None;
    }
    let word = trimmed.split_whitespace().next()?;
    for cmd in COMMANDS {
        let expected = cmd.as_str();
        if word.eq_ignore_ascii_case(expected) && word != expected {
            return Some(format!(
                "command must be uppercase: found `{found}`, expected `{expected}`",
                found = word,
                expected = expected
            ));
        }
    }
    None
}