stack-compiler 0.1.0

Reference compiler frontend for the Stack diagram language
Documentation
use std::{
    error::Error,
    fs,
    path::{Path, PathBuf},
};

use serde_json::{Value, json};
use stack_compiler::{compile_bytes, diagnostic, ir};

#[test]
fn valid_cases_match_normalized_ir() -> Result<(), Box<dyn Error>> {
    let root = specification_root()?.join("conformance/valid");
    for case in case_directories(&root)? {
        let source = fs::read(case.join("source.stack"))?;
        let expected_ir = read_json(&case.join("expected.ir.json"))?;
        let output = compile_bytes(&source);

        let Some(diagram) = output.diagram else {
            return Err(test_error(format!(
                "{} did not produce IR: {:?}",
                case.display(),
                output.diagnostics
            )));
        };
        assert_eq!(
            diagram_json(&diagram),
            expected_ir,
            "IR mismatch in {}",
            case.display()
        );

        let expected_diagnostics_path = case.join("expected.diagnostics.json");
        let expected_diagnostics = if expected_diagnostics_path.exists() {
            read_json(&expected_diagnostics_path)?
        } else {
            json!({ "schemaVersion": "1.0", "diagnostics": [] })
        };
        assert_eq!(
            diagnostics_json(&output.diagnostics, &expected_diagnostics),
            expected_diagnostics,
            "diagnostic mismatch in {}",
            case.display()
        );
    }
    Ok(())
}

#[test]
fn invalid_cases_match_portable_diagnostics() -> Result<(), Box<dyn Error>> {
    let root = specification_root()?.join("conformance/invalid");
    for case in case_directories(&root)? {
        let source = fs::read(case.join("source.stack"))?;
        let expected = read_json(&case.join("expected.diagnostics.json"))?;
        let output = compile_bytes(&source);

        assert!(
            output.diagram.is_none(),
            "invalid case produced IR: {}",
            case.display()
        );
        assert_eq!(
            diagnostics_json(&output.diagnostics, &expected),
            expected,
            "diagnostic mismatch in {}",
            case.display()
        );
    }
    Ok(())
}

fn case_directories(root: &Path) -> Result<Vec<PathBuf>, Box<dyn Error>> {
    let mut cases = Vec::new();
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        if entry.file_type()?.is_dir() {
            cases.push(entry.path());
        }
    }
    cases.sort();
    if cases.is_empty() {
        return Err(test_error(format!(
            "no conformance cases found in {}",
            root.display()
        )));
    }
    Ok(cases)
}

fn read_json(path: &Path) -> Result<Value, Box<dyn Error>> {
    Ok(serde_json::from_slice(&fs::read(path)?)?)
}

fn test_error(message: String) -> Box<dyn Error> {
    Box::new(std::io::Error::other(message))
}

fn diagnostics_json(diagnostics: &[diagnostic::Diagnostic], expected: &Value) -> Value {
    let expected_diagnostics = expected.get("diagnostics").and_then(Value::as_array);
    json!({
        "schemaVersion": "1.0",
        "diagnostics": diagnostics
            .iter()
            .enumerate()
            .map(|(index, diagnostic)| {
                let compare_expected = expected_diagnostics
                    .and_then(|items| items.get(index))
                    .and_then(|item| item.get("expected"))
                    .is_some();
                diagnostic_expectation_json(diagnostic, compare_expected)
            })
            .collect::<Vec<_>>(),
    })
}

fn diagnostic_expectation_json(
    diagnostic: &diagnostic::Diagnostic,
    compare_expected: bool,
) -> Value {
    let mut value = json!({
        "code": diagnostic.code,
        "severity": severity_name(diagnostic.severity),
        "range": range_json(diagnostic.span),
    });
    if compare_expected {
        value["expected"] = json!(diagnostic.expected);
    }
    value
}

fn severity_name(severity: diagnostic::Severity) -> &'static str {
    match severity {
        diagnostic::Severity::Error => "error",
        diagnostic::Severity::Warning => "warning",
    }
}

fn range_json(span: diagnostic::Span) -> Value {
    json!({
        "start": position_json(span.start),
        "end": position_json(span.end),
    })
}

fn position_json(position: diagnostic::SourcePosition) -> Value {
    json!({
        "byteOffset": position.byte_offset,
        "line": position.line,
        "column": position.column,
    })
}

fn diagram_json(diagram: &ir::Diagram) -> Value {
    json!({
        "schemaVersion": "1.0",
        "languageVersion": {
            "major": diagram.language_version.major,
            "minor": diagram.language_version.minor,
        },
        "title": diagram.title,
        "themeId": diagram.theme_id,
        "children": diagram.children.iter().map(element_json).collect::<Vec<_>>(),
        "nodes": diagram.nodes.iter().map(node_json).collect::<Vec<_>>(),
        "groups": diagram.groups.iter().map(group_json).collect::<Vec<_>>(),
        "edges": diagram.edges.iter().map(edge_json).collect::<Vec<_>>(),
        "layout": diagram.layout.as_ref().map(layout_json),
    })
}

fn element_json(element: &ir::ElementId) -> Value {
    match element {
        ir::ElementId::Node(id) => json!({ "type": "node", "id": id }),
        ir::ElementId::Group(id) => json!({ "type": "group", "id": id }),
    }
}

fn node_json(node: &ir::Node) -> Value {
    json!({
        "id": node.id,
        "label": node.label,
        "kind": node_kind_name(node.kind),
        "iconId": node.icon_id,
        "detail": node.detail,
        "parentGroupId": node.parent_group_id,
    })
}

fn node_kind_name(kind: ir::NodeKind) -> &'static str {
    match kind {
        ir::NodeKind::Actor => "actor",
        ir::NodeKind::Client => "client",
        ir::NodeKind::Service => "service",
        ir::NodeKind::Function => "function",
        ir::NodeKind::Worker => "worker",
        ir::NodeKind::Database => "database",
        ir::NodeKind::Cache => "cache",
        ir::NodeKind::Queue => "queue",
        ir::NodeKind::Storage => "storage",
        ir::NodeKind::External => "external",
    }
}

fn group_json(group: &ir::Group) -> Value {
    json!({
        "id": group.id,
        "label": group.label,
        "parentGroupId": group.parent_group_id,
        "children": group.children.iter().map(element_json).collect::<Vec<_>>(),
        "layout": group.layout.as_ref().map(layout_json),
    })
}

fn edge_json(edge: &ir::Edge) -> Value {
    json!({
        "from": edge.from,
        "to": edge.to,
        "direction": edge_direction_name(edge.direction),
        "kind": edge_kind_name(edge.kind),
        "label": edge.label,
    })
}

fn edge_direction_name(direction: ir::EdgeDirection) -> &'static str {
    match direction {
        ir::EdgeDirection::Forward => "forward",
        ir::EdgeDirection::Bidirectional => "bidirectional",
        ir::EdgeDirection::Association => "association",
    }
}

fn edge_kind_name(kind: ir::EdgeKind) -> &'static str {
    match kind {
        ir::EdgeKind::Flow => "flow",
        ir::EdgeKind::Request => "request",
        ir::EdgeKind::Event => "event",
        ir::EdgeKind::Data => "data",
        ir::EdgeKind::Dependency => "dependency",
    }
}

fn layout_json(layout: &ir::Layout) -> Value {
    json!({
        "direction": layout.direction.map(direction_name),
        "sameRanks": layout.same_ranks,
        "order": layout.order,
    })
}

fn direction_name(direction: ir::Direction) -> &'static str {
    match direction {
        ir::Direction::Right => "right",
        ir::Direction::Down => "down",
    }
}

fn specification_root() -> Result<PathBuf, Box<dyn Error>> {
    let path = std::env::var_os("STACK_SPECIFICATION_DIR").ok_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "STACK_SPECIFICATION_DIR must identify a specification checkout",
        )
    })?;
    Ok(PathBuf::from(path))
}