drawlang-syntax 0.1.2

Lexer, parser, lossless syntax tree, and formatter for the drawlang DSL
Documentation
use drawlang_syntax::ast::*;
use drawlang_syntax::diag::Severity;
use drawlang_syntax::parse_source;
use drawlang_syntax::span::SourceFile;

fn example(name: &str) -> String {
    let path = format!("{}/../../examples/{name}", env!("CARGO_MANIFEST_DIR"));
    std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}

#[test]
fn examples_parse_clean() {
    for name in ["hello.drawl", "gpu-topology.drawl", "cpu-arch.drawl"] {
        let parsed = parse_source(&example(name));
        let src = SourceFile::new(name, example(name));
        let rendered: Vec<String> = parsed
            .diagnostics
            .iter()
            .map(|d| {
                drawlang_syntax::diag::render(d, &src, &drawlang_syntax::diag::Styles::plain())
            })
            .collect();
        assert!(
            parsed.diagnostics.is_empty(),
            "{name} should parse clean, got:\n{}",
            rendered.join("\n")
        );
    }
}

/// M0 exit criterion: a file with 5 seeded errors reports all 5, each with a
/// span pointing at the right source text.
#[test]
fn five_seeded_errors_all_reported_with_correct_spans() {
    let src = r#"drawl 0.1

canvas { theme paper }
node_a { label: "unterminated
node_b { width: 100px }
edge_x -> : "missing target"
constrain { align(node_a, node_b "#;

    let parsed = parse_source(src);
    let errors: Vec<_> = parsed
        .diagnostics
        .iter()
        .filter(|d| d.severity == Severity::Error)
        .collect();

    let file = SourceFile::new("seeded.drawl", src);
    let snippets: Vec<(&str, String)> = errors
        .iter()
        .map(|d| {
            let span = d.primary_span().expect("every error carries a span");
            (d.code, file.snippet(span).to_string())
        })
        .collect();

    assert!(
        errors.len() >= 5,
        "expected at least 5 errors, got {}: {snippets:?}",
        errors.len()
    );

    // Each seeded mistake is individually caught at the right location.
    let has = |code: &str, frag: &str| {
        snippets
            .iter()
            .any(|(c, text)| *c == code && (text.contains(frag) || frag.is_empty()))
    };
    // 1. `theme paper` — missing colon: ident followed by ident.
    assert!(
        errors
            .iter()
            .any(|e| e.code == "E0103" || e.code == "E0110"),
        "missing-colon error not reported: {snippets:?}"
    );
    // 2. Unterminated string.
    assert!(
        has("E0102", "\"unterminated"),
        "unterminated string not caught: {snippets:?}"
    );
    // 3. `100px` — unit suffix.
    assert!(has("E0105", "px"), "unit suffix not caught: {snippets:?}");
    // 4. Edge with missing target (`-> :`).
    assert!(
        errors.iter().any(
            |e| e.code == "E0103" && (e.message.contains("path") || e.message.contains("name"))
        ),
        "missing edge target not caught: {snippets:?}"
    );
    // 5. Unclosed constrain block at EOF.
    assert!(
        errors
            .iter()
            .any(|e| e.message.contains("unclosed") || e.message.contains("close")),
        "unclosed constrain block not caught: {snippets:?}"
    );
}

#[test]
fn recovery_continues_after_errors() {
    // The bad statement must not swallow the good ones around it.
    let src = "drawl 0.1\na { label: \"A\" }\nx: : 5\nb { label: \"B\" }\na -> b\n";
    let parsed = parse_source(src);
    assert!(parsed.has_errors());
    let nodes: Vec<&str> = parsed
        .file
        .stmts
        .iter()
        .filter_map(|s| match &s.kind {
            StmtKind::Node(n) => n.name.as_ref().map(|i| i.name.as_str()),
            _ => None,
        })
        .collect();
    assert_eq!(
        nodes,
        vec!["a", "b"],
        "good statements must survive recovery"
    );
    assert!(
        parsed
            .file
            .stmts
            .iter()
            .any(|s| matches!(s.kind, StmtKind::Edge(_))),
        "edge after the error must survive recovery"
    );
}

#[test]
fn dimension_and_range_lexing() {
    let src = "drawl 0.1\ng: grid 2x4 { }\nfor i in 0..4 { x { } }\n";
    let parsed = parse_source(src);
    assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
    let grid = parsed.file.stmts.iter().find_map(|s| match &s.kind {
        StmtKind::Node(Node {
            kind: NodeKind::Container { ctype, .. },
            ..
        }) => Some(*ctype),
        _ => None,
    });
    assert_eq!(grid, Some(ContainerType::Grid { cols: 2, rows: 4 }));
}

#[test]
fn interpolation_spans_point_into_source() {
    let src = "drawl 0.1\nn { label: \"GPU {idx + 1}\" }\n";
    let parsed = parse_source(src);
    assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
    let label = parsed.file.stmts.iter().find_map(|s| match &s.kind {
        StmtKind::Node(Node {
            kind: NodeKind::Plain { body },
            ..
        }) => body.stmts.iter().find_map(|p| match &p.kind {
            StmtKind::Prop(Prop {
                value: Value::Str(sl),
                ..
            }) => Some(sl.clone()),
            _ => None,
        }),
        _ => None,
    });
    let label = label.expect("label prop");
    assert_eq!(label.parts.len(), 2);
    match &label.parts[1] {
        StrPart::Expr(e) => {
            // The expression span must point at `idx + 1` in the real file.
            assert_eq!(&src[e.span.start..e.span.end], "idx + 1");
        }
        other => panic!("expected expr part, got {other:?}"),
    }
}

#[test]
fn did_you_mean_keyword_suggestions() {
    let src = "drawl 0.1\ngroop host \"Host\" { }\n";
    let parsed = parse_source(src);
    let d = parsed
        .diagnostics
        .iter()
        .find(|d| d.code == "E0110")
        .expect("E0110");
    assert!(
        d.help.as_deref().unwrap_or("").contains("`group`"),
        "expected did-you-mean group, got {:?}",
        d.help
    );
}

#[test]
fn back_arrow_normalizes_to_forward() {
    let src = "drawl 0.1\na\nb\na <- b\n";
    let parsed = parse_source(src);
    assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
    let edge = parsed.file.stmts.iter().find_map(|s| match &s.kind {
        StmtKind::Edge(e) => Some(e),
        _ => None,
    });
    let edge = edge.expect("edge");
    assert_eq!(edge.op, EdgeOp::Forward);
    assert_eq!(edge.from.display(), "b");
    assert_eq!(edge.to.display(), "a");
}