bimr-engine/parser/src/parser.rs
2026-09-01 15:32:49 +02:00

298 lines
10 KiB
Rust

// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::ast::*;
use chumsky::prelude::*;
use chumsky::{input::Stream, input::ValueInput};
use lexer::Token;
use logos::Logos;
/// Parses a BIMR DSL source string into a `Program` AST.
///
/// Lexes `input` with `logos`, wraps the token iterator in a `chumsky`
/// `Stream`, and runs the `program` combinator. Returns the AST on success or
/// a list of `Simple` parse errors on failure.
pub fn parse(input: &str) -> Result<Program, Vec<Simple<'_, Token>>> {
let token_iter = Token::lexer(input).spanned().map(|(tok, span)| match tok {
Ok(tok) => (tok, SimpleSpan::from(span)),
Err(()) => (Token::Error, span.into()),
});
let token_stream = Stream::from_iter(token_iter).map((0..input.len()).into(), |(t, s)| (t, s));
program().parse(token_stream).into_result()
}
/// Parser combinator for a complete BIMR program.
///
/// A program is zero or more `statement`s collected into a `Program` node.
fn program<'tokens, I>() -> impl Parser<'tokens, I, Program, extra::Err<Simple<'tokens, Token>>>
where
I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>,
{
statement()
.repeated()
.collect()
.map(|statements| Program { statements })
}
/// Parser combinator for a single assignment statement.
///
/// Grammar: `identifier = expression`
fn statement<'tokens, I>() -> impl Parser<'tokens, I, Statement, extra::Err<Simple<'tokens, Token>>>
where
I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>,
{
identifier()
.then_ignore(just(Token::Equal))
.then(expression())
.map(|(identifier, expression)| Statement {
assignment: Assignment {
identifier,
expression,
},
})
}
/// Parser combinator for an expression (RHS of an assignment).
///
/// Tries, in order: function call, tuple, bare atom (identifier / number /
/// string). Function calls are tried first to avoid mis-parsing `Name(…)` as
/// a bare identifier followed by junk.
fn expression<'tokens, I>()
-> impl Parser<'tokens, I, Expression, extra::Err<Simple<'tokens, Token>>>
where
I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>,
{
// Atomic expressions — no tuple here to avoid ambiguity with function call parens.
let atom = || {
choice((
identifier().map(Expression::Identifier),
select! { Token::Float(bits) => Expression::Float(f64::from_bits(bits)) },
select! { Token::Integer(i) => Expression::Integer(i) },
select! { Token::String(s) => Expression::String(s) },
))
};
// Tuple: (expr, expr) or (expr, expr, expr) — at least two elements.
// Used for inline point literals inside List(...) args.
// Constructed as a closure so it can be used in multiple places without Clone.
let tuple = || {
atom()
.separated_by(just(Token::Comma))
.at_least(2)
.collect::<Vec<_>>()
.delimited_by(just(Token::LeftParen), just(Token::RightParen))
.map(Expression::Tuple)
};
// A single argument: either `ident=expr` (keyword) or `expr` (positional).
// atom() is used as the value expression to keep things non-recursive.
let argument = identifier()
.then_ignore(just(Token::Equal))
.then(atom())
.map(|(key, val)| Argument::Keyword(key, val))
.or(choice((tuple(), atom())).map(Argument::Positional));
// Function call: Identifier(arg, ...)
let function_call = identifier()
.then(
argument
.separated_by(just(Token::Comma))
.allow_trailing()
.collect::<Vec<_>>()
.delimited_by(just(Token::LeftParen), just(Token::RightParen)),
)
.map(|(name, arguments)| Expression::FunctionCall(FunctionCall { name, arguments }));
choice((function_call, tuple(), atom()))
}
/// Parser combinator that matches a single `Token::Identifier` and returns
/// its string value.
fn identifier<'tokens, I>() -> impl Parser<'tokens, I, String, extra::Err<Simple<'tokens, Token>>>
where
I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>,
{
select! { Token::Identifier(s) => s }
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_one(src: &str) -> Program {
parse(src).expect("parse failed")
}
// ── Basic assignments ─────────────────────────────────────────────────────
#[test]
fn test_parse_integer() {
let prog = parse_one("x = 42");
assert_eq!(prog.statements.len(), 1);
let stmt = &prog.statements[0].assignment;
assert_eq!(stmt.identifier, "x");
assert!(matches!(stmt.expression, Expression::Integer(42)));
}
#[test]
#[allow(clippy::approx_constant)]
fn test_parse_float() {
let prog = parse_one("x = 3.14");
let expr = &prog.statements[0].assignment.expression;
assert!(matches!(expr, Expression::Float(v) if (v - 3.14).abs() < 1e-10));
}
#[test]
fn test_parse_negative_int() {
let prog = parse_one("x = -7");
let expr = &prog.statements[0].assignment.expression;
assert!(matches!(expr, Expression::Integer(-7)));
}
#[test]
fn test_parse_string() {
// The lexer preserves the surrounding quotes in the token value.
let prog = parse_one(r#"x = "hello""#);
let expr = &prog.statements[0].assignment.expression;
assert!(matches!(expr, Expression::String(s) if s == r#""hello""#));
}
#[test]
fn test_parse_identifier() {
let prog = parse_one("x = y");
let expr = &prog.statements[0].assignment.expression;
assert!(matches!(expr, Expression::Identifier(id) if id == "y"));
}
// ── Function calls ────────────────────────────────────────────────────────
#[test]
fn test_parse_point() {
let prog = parse_one("p1 = Point(0, 0, 0)");
let expr = &prog.statements[0].assignment.expression;
if let Expression::FunctionCall(fc) = expr {
assert_eq!(fc.name, "Point");
assert_eq!(fc.arguments.len(), 3);
} else {
panic!("expected FunctionCall");
}
}
#[test]
fn test_parse_line() {
let prog = parse_one("l1 = Line(p1, p2)");
let expr = &prog.statements[0].assignment.expression;
if let Expression::FunctionCall(fc) = expr {
assert_eq!(fc.name, "Line");
assert_eq!(fc.arguments.len(), 2);
} else {
panic!("expected FunctionCall");
}
}
#[test]
fn test_parse_wall_with_keywords() {
let prog = parse_one("w1 = Wall(l1, thickness=200, height=3000)");
let expr = &prog.statements[0].assignment.expression;
if let Expression::FunctionCall(fc) = expr {
assert_eq!(fc.name, "Wall");
assert_eq!(fc.arguments.len(), 3);
assert!(matches!(&fc.arguments[0], Argument::Positional(_)));
assert!(matches!(&fc.arguments[1], Argument::Keyword(k, _) if k == "thickness"));
assert!(matches!(&fc.arguments[2], Argument::Keyword(k, _) if k == "height"));
} else {
panic!("expected FunctionCall");
}
}
#[test]
fn test_parse_each_operator() {
let src = r#"e1 = Each(div, "Frame", fw=200, height=300, tangent="auto")"#;
let prog = parse_one(src);
let expr = &prog.statements[0].assignment.expression;
if let Expression::FunctionCall(fc) = expr {
assert_eq!(fc.name, "Each");
assert!(fc.arguments.len() >= 2);
} else {
panic!("expected FunctionCall");
}
}
// ── Multiple statements ───────────────────────────────────────────────────
#[test]
fn test_parse_multiple_statements() {
let src = "p1 = Point(0, 0, 0)\np2 = Point(100, 0, 0)\nl1 = Line(p1, p2)";
let prog = parse_one(src);
assert_eq!(prog.statements.len(), 3);
}
#[test]
fn test_parse_empty_program() {
let prog = parse_one("");
assert!(prog.statements.is_empty());
}
// ── Inline tuples ─────────────────────────────────────────────────────────
#[test]
fn test_parse_tuple_in_list() {
let src = "lst = List((0, 0, 0), (100, 0, 0))";
let prog = parse_one(src);
let expr = &prog.statements[0].assignment.expression;
if let Expression::FunctionCall(fc) = expr {
assert_eq!(fc.name, "List");
assert_eq!(fc.arguments.len(), 2);
for arg in &fc.arguments {
assert!(matches!(arg, Argument::Positional(Expression::Tuple(_))));
}
} else {
panic!("expected FunctionCall");
}
}
// ── Comments ──────────────────────────────────────────────────────────────
#[test]
fn test_parse_with_comments() {
let src =
"# a full-line comment\np1 = Point(0, 0, 0) # trailing comment\nl1 = Line(p1, p1)";
let prog = parse_one(src);
assert_eq!(prog.statements.len(), 2);
}
#[test]
fn test_parse_comment_before_statement() {
let prog = parse_one("# nothing here yet\nx = 42");
assert_eq!(prog.statements.len(), 1);
assert_eq!(prog.statements[0].assignment.identifier, "x");
}
// ── Error handling ────────────────────────────────────────────────────────
#[test]
fn test_parse_error_missing_paren() {
let result = parse("p1 = Point(0, 0");
assert!(result.is_err());
}
#[test]
fn test_parse_error_unclosed_string() {
let result = parse(r#"x = "hello"#);
assert!(result.is_err());
}
#[test]
fn test_parse_error_bad_syntax() {
let result = parse("= 42");
assert!(result.is_err());
}
#[test]
fn test_parse_error_unknown_token() {
let result = parse("x = @@@");
assert!(result.is_err());
}
}