diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cbd450f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install latest stable Rust toolchain + run: rustup toolchain install stable --profile minimal --component clippy,rustfmt,llvm-tools-preview + + - name: Check formatting + run: cargo +stable fmt --check + + - name: Run tests + run: cargo +stable test + + - name: Run Clippy + run: cargo +stable clippy --all-targets --all-features -- -D warnings + + - name: Build documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo +stable doc --no-deps + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 + with: + tool: cargo-llvm-cov@0.9.0 + fallback: none + + - name: Enforce unit-test coverage + run: cargo +stable llvm-cov --lib --all-features --workspace --fail-under-lines 95 --fail-under-functions 95 --fail-under-regions 95 + + msrv: + name: Minimum supported Rust + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install Rust 1.85 + run: rustup toolchain install 1.85.0 --profile minimal --component clippy + + - name: Run tests + run: cargo +1.85.0 test + + - name: Run Clippy + run: cargo +1.85.0 clippy --all-targets --all-features -- -D warnings diff --git a/.gitignore b/.gitignore index 872d5f6..8cafac0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Logs logs *.log + +# Rust build output +/target npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3742795 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Stack Compiler Agent Guide + +## Source of Truth + +The canonical language contract is `stack-sh/specification`. Never introduce syntax, defaults, semantics, or `STK` diagnostic meanings that are not defined there. + +## Technology + +- Rust 2024 edition +- Latest stable Rust and Cargo for development and primary CI +- Rust 1.85 minimum supported version, verified separately in CI +- Standard library only unless an ADR accepts a dependency +- No unsafe Rust + +## Commands + +- Format: `cargo fmt --check` +- Test: `cargo test` +- Unit-test coverage: `cargo llvm-cov --lib --all-features --workspace --fail-under-lines 95 --fail-under-functions 95 --fail-under-regions 95` +- Lint: `cargo clippy --all-targets --all-features -- -D warnings` +- Documentation: `cargo doc --no-deps` + +## Conventions + +- Keep parsing, semantic validation, and normalization as separate stages. +- Preserve source spans and duplicate declarations in the AST so validation can report the authored mistake. +- Keep the normalized IR deterministic, renderer-independent, and free of filesystem or network handles. +- Use specification-assigned `STK` codes only for their normative meanings. +- Add focused tests for every diagnostic or language rule implemented. +- Keep line, function, and region coverage at or above 95 percent. +- Keep GitHub Actions on their latest supported major versions; Dependabot checks for updates weekly. +- Do not use panic-producing `unwrap`, `expect`, `panic`, `unreachable`, `todo`, or `unimplemented` macros in any target; package-level Clippy lints enforce this boundary. +- Write repository content, code comments, issues, and pull requests in English. +- Keep temporary implementation plans and task lists outside the repository under `/tmp`. + +## Boundaries + +- Always run formatting, tests, coverage, Clippy, and documentation checks before delivery. +- Ask before adding a runtime dependency or changing a public representation. +- Do not create a commit unless the user explicitly requests one. +- Never add theme resolution, icon retrieval, layout, SVG rendering, HTTP, authentication, or storage access to this repository. +- Never add temporary plan or todo files to the repository or its ignore rules. +- Never commit credentials, generated build output, or editor-specific state. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a6b9f70 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "stack-compiler" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0f86909 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "stack-compiler" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "Apache-2.0" +description = "Reference compiler frontend for the Stack diagram language" +repository = "https://github.com/stack-sh/compiler" +readme = "README.md" +keywords = ["stack", "diagram", "dsl", "compiler"] +categories = ["parser-implementations"] + +[lib] +path = "src/lib.rs" + +[lints.clippy] +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unreachable = "deny" +unwrap_used = "deny" diff --git a/README.md b/README.md index 18c0f16..17c7dfe 100644 --- a/README.md +++ b/README.md @@ -1 +1,46 @@ -# compiler \ No newline at end of file +# Stack Compiler + +`stack-compiler` is the reference Rust frontend for the [Stack language](https://github.com/stack-sh/specification). It parses Stack source, validates its language semantics, applies specification-defined defaults, and produces a renderer-independent normalized diagram. + +The compiler does not resolve themes or icons, calculate layout, render SVG, access the network, or read files. Those concerns belong to downstream libraries and applications. + +## Status + +Stack 1.0 and this compiler are both under active development. Public Rust APIs may change before the first stable release. + +Development and primary CI follow the latest stable Rust and Cargo releases through [`rust-toolchain.toml`](./rust-toolchain.toml). Rust 1.85 remains the minimum supported version and is verified in a separate CI job. + +## Pipeline + +```text +Stack source + -> lexical analysis + -> syntax AST + -> semantic validation + -> normalized Diagram IR +``` + +## Commands + +| Command | Purpose | +| --- | --- | +| `cargo test` | Run the test suite | +| `cargo llvm-cov --lib --all-features --workspace --fail-under-lines 95 --fail-under-functions 95 --fail-under-regions 95` | Enforce unit-test coverage | +| `cargo fmt --check` | Check Rust formatting | +| `cargo clippy --all-targets --all-features -- -D warnings` | Run the linter | +| `cargo doc --no-deps` | Build API documentation | + +Install [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov) before running the coverage command. CI measures the library unit tests independently and requires line, function, and region coverage to remain at or above 95 percent. + +Package-level Clippy lints reject panic-producing `unwrap`, `expect`, `panic`, `unreachable`, `todo`, and `unimplemented` calls in library and test targets. + +## Architecture + +- [`docs/decisions/0001-build-a-portable-rust-compiler-core.md`](./docs/decisions/0001-build-a-portable-rust-compiler-core.md) +- [`docs/decisions/0002-separate-syntax-ast-from-normalized-ir.md`](./docs/decisions/0002-separate-syntax-ast-from-normalized-ir.md) +- [`docs/decisions/0003-use-a-handwritten-parser.md`](./docs/decisions/0003-use-a-handwritten-parser.md) +- [`docs/specs/compiler-frontend.md`](./docs/specs/compiler-frontend.md) + +## License + +Apache-2.0 diff --git a/docs/decisions/0001-build-a-portable-rust-compiler-core.md b/docs/decisions/0001-build-a-portable-rust-compiler-core.md new file mode 100644 index 0000000..e17abb3 --- /dev/null +++ b/docs/decisions/0001-build-a-portable-rust-compiler-core.md @@ -0,0 +1,61 @@ +# ADR-0001: Build a Portable Rust Compiler Core + +## Status + +Accepted + +## Date + +2026-09-02 + +## Context + +Stack source must produce the same meaning in a browser, a native CLI, and a hosted rendering service. The initial hosted design may use Cloudflare Workers, but the language implementation must not depend on one HTTP runtime, storage provider, or commercial product boundary. + +The compiler is small and CPU-bound. It does not need theme assets, network access, a filesystem, or renderer state. Rust can produce native libraries and binaries for CLI use and WebAssembly modules for browser or Worker use from the same core implementation. + +## Decision + +Implement the Stack compiler frontend as a pure Rust library. + +This repository owns the pipeline from decoded Stack source through normalized diagram IR: + +1. lexical analysis; +2. syntax parsing; +3. identifier and default resolution; +4. semantic and complexity validation; +5. normalized IR construction. + +The core API accepts source bytes or text and returns typed values and structured diagnostics. It must be deterministic and must not perform network, filesystem, clock, random, environment, or platform-specific operations. + +Native Rust is the first supported target. WebAssembly bindings and a native CLI may wrap the same core in later changes, but target-specific adapters must remain outside the compiler stages. + +Theme and icon resolution, layout, SVG rendering, HTTP handling, authentication, caching, and storage are explicitly outside this repository. + +## Alternatives Considered + +### TypeScript-only compiler + +- Pros: Direct integration with browsers and Cloudflare Workers; shared types with web applications. +- Cons: Makes a native CLI less direct and ties the reference implementation more closely to JavaScript runtimes. +- Rejected: Rust provides the desired native and WebAssembly portability while a thin TypeScript host can still call the compiled module. + +### Go compiler in a container service + +- Pros: Straightforward native service deployment and good server concurrency. +- Cons: Browser execution and Worker integration are less direct; adopting a container as the language boundary couples local rendering to a hosted API. +- Rejected: A portable local engine is a primary product property, not only a deployment optimization. + +### Hosted API as the only compiler interface + +- Pros: One centrally deployed implementation and simple client code. +- Cons: Requires network access, adds operating cost and latency, and prevents private offline rendering. +- Rejected: Hosted rendering may be offered later as a managed convenience, but it is not required for language correctness. + +## Consequences + +- Browser, CLI, and hosted products can share one language implementation. +- The compiler can be tested without infrastructure or external assets. +- Rust-to-WebAssembly interface design and binary size require explicit verification later. +- Downstream repositories consume normalized IR rather than syntax-specific AST details. +- Commercial services must add value through hosting, collaboration, private assets, governance, or support rather than exclusive access to language semantics. diff --git a/docs/decisions/0002-separate-syntax-ast-from-normalized-ir.md b/docs/decisions/0002-separate-syntax-ast-from-normalized-ir.md new file mode 100644 index 0000000..fe497d3 --- /dev/null +++ b/docs/decisions/0002-separate-syntax-ast-from-normalized-ir.md @@ -0,0 +1,55 @@ +# ADR-0002: Separate the Syntax AST from Normalized IR + +## Status + +Accepted + +## Date + +2026-09-02 + +## Context + +A parser must represent what an author wrote, including omitted properties, source order, duplicate properties, unresolved identifiers, and precise source locations. Layout and rendering code should not need to interpret those syntax choices or independently apply Stack defaults. + +Using the syntax AST as the cross-repository interface would expose grammar details to every downstream consumer. It would also force each consumer to repeat semantic validation and normalization. + +## Decision + +Expose two distinct representations: + +- The syntax AST mirrors Stack declarations and properties. It preserves source spans, authored order, omissions, and duplicates needed for diagnostics. +- The normalized IR represents a semantically valid diagram. It applies specification-defined defaults, resolves structural membership, separates nodes, groups, and edges, and uses typed enums for closed semantic values. + +Semantic validation is a distinct pass between the two representations. The compiler produces normalized IR only when lexical, syntax, and semantic errors are absent. Warnings do not prevent IR construction. + +The normalized IR must be deterministic, serializable without runtime handles, and independent of themes, layout engines, and renderers. Renderer-selected defaults, resolved theme data, icon SVG, coordinates, and text metrics do not belong in this IR. + +Source positions use one-based line and column numbers in diagnostics. Internally, spans also retain zero-based UTF-8 byte offsets. Range ends are exclusive. + +## Alternatives Considered + +### Let layout consume the AST + +- Pros: No additional representation or lowering pass. +- Cons: Couples layout to grammar and duplicates defaulting, reference resolution, and validation across consumers. +- Rejected: Syntax is not a stable renderer contract. + +### Normalize the AST in place + +- Pros: Fewer data types and allocations. +- Cons: Loses whether a value was authored or defaulted and prevents precise duplicate-property diagnostics. +- Rejected: Diagnostic tooling and downstream rendering need different information. + +### Include resolved theme and icon assets in compiler IR + +- Pros: One object contains everything needed by layout. +- Cons: Makes compilation depend on a catalog version and external assets, and prevents pure offline language validation. +- Rejected: Theme resolution creates a later visual representation owned by downstream code. + +## Consequences + +- AST types may evolve with grammar additions, while normalized IR is the intentional downstream boundary. +- Parser and validator tests can assert authored syntax independently from normalized meaning. +- The compiler performs a small allocation cost to lower from AST to IR. +- Future formatter work may add a concrete syntax or token representation for comment preservation without placing trivia in normalized IR. diff --git a/docs/decisions/0003-use-a-handwritten-parser.md b/docs/decisions/0003-use-a-handwritten-parser.md new file mode 100644 index 0000000..2c7c3ff --- /dev/null +++ b/docs/decisions/0003-use-a-handwritten-parser.md @@ -0,0 +1,46 @@ +# ADR-0003: Use a Handwritten Parser + +## Status + +Accepted + +## Date + +2026-09-02 + +## Context + +Stack 1.0 has a small, intentionally constrained grammar with contextual keywords. Diagnostics require exact source spans and stable Stack-specific error codes. The parser must preserve duplicate declarations and properties for a later semantic validation pass. + +A parser generator could reduce some grammar code, but it would add a runtime dependency and make error recovery, contextual identifiers, and diagnostic mapping depend on generator behavior. + +## Decision + +Use a handwritten lexer and recursive-descent parser for Stack 1.x. + +The lexer produces spanned tokens and handles comments, strings, escapes, punctuation, operators, and UTF-8 position tracking. Keywords remain ordinary word tokens and are interpreted by the parser according to grammatical context. + +The parser follows the canonical EBNF directly. It rejects unknown declarations, properties, values, and operators. Initial syntax recovery may stop after the first lexical or syntax error because multi-error parser recovery is optional in Stack 1.0. Semantic validation must still collect independent errors in one pass. + +No parser dependency is added initially. A future ADR may replace this parser if grammar growth or recovery requirements make the handwritten implementation materially harder to maintain. + +## Alternatives Considered + +### Parser generator + +- Pros: Declarative grammar and generated parsing machinery. +- Cons: Additional dependency, less direct control over Stack diagnostics, and generator-specific recovery behavior. +- Rejected for the initial grammar: The language is small enough for direct implementation. + +### Parser combinator library + +- Pros: Reusable primitives and compact parser code. +- Cons: Additional dependency and error composition that may not align with normative diagnostic codes. +- Rejected for the initial grammar: Standard-library code is sufficient. + +## Consequences + +- Parser code remains explicit and easy to compare with the EBNF. +- Source range and diagnostic behavior stay under project control. +- Grammar changes require corresponding manual parser and test updates. +- The project must keep parsing functions small and structurally aligned with specification productions. diff --git a/docs/specs/compiler-frontend.md b/docs/specs/compiler-frontend.md new file mode 100644 index 0000000..3103207 --- /dev/null +++ b/docs/specs/compiler-frontend.md @@ -0,0 +1,120 @@ +# Compiler Frontend Specification + +## Objective + +Build the first reference frontend for Stack 1.0. It must parse canonical Stack source, expose a source-oriented AST, collect portable semantic diagnostics, apply language defaults, and produce normalized renderer-independent IR. + +The primary users are Stack CLI, browser, editor, layout, and hosted-service implementations that need identical language semantics. + +## Technology + +- Rust 2024 edition +- Minimum Rust version: 1.85 +- Development and primary CI toolchain: latest stable Rust and Cargo +- No runtime dependencies in the initial frontend +- No unsafe Rust + +## Commands + +- Build: `cargo build` +- Test: `cargo test` +- Unit-test coverage: `cargo llvm-cov --lib --all-features --workspace --fail-under-lines 95 --fail-under-functions 95 --fail-under-regions 95` +- Format: `cargo fmt --check` +- Lint: `cargo clippy --all-targets --all-features -- -D warnings` +- Documentation: `cargo doc --no-deps` + +## Project Structure + +```text +src/ast.rs Syntax-oriented AST and spans +src/diagnostic.rs Portable diagnostic types and codes +src/lexer.rs UTF-8 text tokenization +src/parser.rs Recursive-descent grammar implementation +src/validation.rs Semantic validation and normalization +src/validation/ Focused validation unit tests +src/ir.rs Renderer-independent normalized model +src/lib.rs Public parse and compile APIs +tests/ Public API and conformance-oriented tests +docs/decisions/ Architectural decisions +``` + +## Public API Shape + +```rust +pub fn parse(source: &str) -> ParseOutput; +pub fn parse_bytes(source: &[u8]) -> ParseOutput; +pub fn compile(source: &str) -> CompileOutput; +pub fn compile_bytes(source: &[u8]) -> CompileOutput; +``` + +`ParseOutput` contains an AST only when decoding, lexing, and parsing succeed. `CompileOutput` contains normalized IR only when all compiler-stage errors are absent. Both outputs contain diagnostics. Semantic warnings may accompany successful IR. + +## Code Style + +Prefer explicit domain types and exhaustive matches: + +```rust +match operator { + ast::EdgeOperator::Forward => ir::EdgeDirection::Forward, + ast::EdgeOperator::Bidirectional => ir::EdgeDirection::Bidirectional, + ast::EdgeOperator::Association => ir::EdgeDirection::Association, +} +``` + +- Use `snake_case` for modules and functions and `UpperCamelCase` for types. +- Keep parser functions aligned with named EBNF productions. +- Preserve authored duplicates in AST collections; reject them during validation. +- Avoid speculative abstractions and platform adapters. +- Avoid panic-producing extraction and placeholder macros. Package-level Clippy lints deny `unwrap`, `expect`, `panic`, `unreachable`, `todo`, and `unimplemented` calls. + +## Testing Strategy + +- Unit tests cover lexer escapes, positions, parser productions, defaults, and each implemented diagnostic. +- Integration tests exercise public `parse` and `compile` APIs. +- Valid fixtures mirror the canonical specification examples. +- Invalid cases assert stable diagnostic codes rather than entire prose messages. +- Library unit-test line, function, and region coverage must each remain at or above 95 percent. +- Every compiler change must pass formatting, tests, coverage, Clippy, and documentation builds. +- CI must also pass the complete test and Clippy suites on the minimum supported Rust version. + +## Boundaries + +### Always + +- Follow `stack-sh/specification` grammar, semantics, limits, and diagnostic assignments. +- Keep AST, validation, and normalized IR as separate stages. +- Return deterministic diagnostics and IR. +- Treat source as untrusted plain text. + +### Ask First + +- Add a runtime dependency. +- Change an established public Rust type or normalized IR field. +- Implement syntax not present in the canonical specification. + +### Never + +- Fetch themes or icons. +- Perform layout or rendering. +- Read source from disk inside the core API. +- Interpret source strings as markup, paths, code, or URLs. +- Produce IR when compiler-stage errors exist. + +## Initial Success Criteria + +- All four canonical Stack examples parse and compile. +- Specification-defined defaults appear in normalized IR. +- UTF-8, BOM, invalid string, syntax, name-resolution, semantic, layout-scope, and complexity diagnostics use their assigned codes. +- Independent semantic errors are collected in one validation pass. +- The crate builds without unsafe code or runtime dependencies. +- Formatting, tests, Clippy, and API documentation checks pass. + +## Deferred Work + +- WebAssembly bindings +- Native CLI commands +- Formatter and comment-preserving concrete syntax tree +- Theme and icon resolution +- Layout and renderer integration +- Multi-error syntax recovery +- Stable serialization schema and package versioning diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8d71037 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "minimal" +components = ["clippy", "rustfmt", "llvm-tools-preview"] diff --git a/src/ast.rs b/src/ast.rs new file mode 100644 index 0000000..872b717 --- /dev/null +++ b/src/ast.rs @@ -0,0 +1,200 @@ +//! Source-oriented abstract syntax tree. + +use crate::diagnostic::{Span, Spanned}; + +/// A parsed Stack document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Document { + /// Authored language version. + pub version: Version, + /// The document's single diagram. + pub diagram: Diagram, + /// Span of the complete document. + pub span: Span, +} + +/// A `major.minor` language version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Version { + /// Authored major number. + pub major: u32, + /// Authored minor number. + pub minor: u32, + /// Span of the complete version directive. + pub span: Span, +} + +/// The root diagram declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagram { + /// Visible diagram title. + pub title: Spanned, + /// Authored declarations in source order. + pub members: Vec, + /// Span of the complete declaration. + pub span: Span, +} + +/// A declaration allowed directly inside a diagram. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiagramMember { + /// Node declaration. + Node(Node), + /// Group declaration. + Group(Group), + /// Edge declaration. + Edge(Edge), + /// Theme selection. + Theme(Theme), + /// Layout block. + Layout(Layout), +} + +/// A theme selection statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Theme { + /// Authored theme identifier. + pub identifier: Spanned, + /// Span of the complete statement. + pub span: Span, +} + +/// A labeled containment group. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + /// Source identifier. + pub identifier: Spanned, + /// Visible group label. + pub label: Spanned, + /// Authored group members in source order. + pub members: Vec, + /// Span of the complete declaration. + pub span: Span, +} + +/// A declaration allowed directly inside a group. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupMember { + /// Node declaration. + Node(Node), + /// Nested group declaration. + Group(Group), + /// Scoped layout block. + Layout(Layout), +} + +/// An architectural node declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Node { + /// Source identifier. + pub identifier: Spanned, + /// Visible node label. + pub label: Spanned, + /// Authored properties in source order. + pub properties: Vec, + /// Span of the complete declaration. + pub span: Span, +} + +/// A property in a node block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NodeProperty { + /// Authored node kind. + Kind(Spanned), + /// Authored theme-local icon identifier. + Icon(Spanned), + /// Authored visible detail. + Detail(Spanned), +} + +impl NodeProperty { + /// Returns this property's value span. + pub fn span(&self) -> Span { + match self { + Self::Kind(value) | Self::Icon(value) | Self::Detail(value) => value.span, + } + } +} + +/// An edge declaration between two identifiers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + /// Left endpoint reference. + pub from: Spanned, + /// Authored edge operator. + pub operator: Spanned, + /// Right endpoint reference. + pub to: Spanned, + /// Optional visible edge label. + pub label: Option>, + /// Authored edge properties in source order. + pub properties: Vec, + /// Span of the complete declaration. + pub span: Span, +} + +/// Directionality expressed by an edge operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EdgeOperator { + /// `->` + Forward, + /// `<->` + Bidirectional, + /// `--` + Association, +} + +/// A property in an edge block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EdgeProperty { + /// Authored relationship kind. + Kind(Spanned), +} + +impl EdgeProperty { + /// Returns this property's value span. + pub fn span(&self) -> Span { + match self { + Self::Kind(value) => value.span, + } + } +} + +/// A scoped collection of layout statements. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + /// Authored statements in source order. + pub statements: Vec, + /// Span of the complete block. + pub span: Span, +} + +/// A layout constraint or hint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayoutStatement { + /// Preferred flow direction. + Direction(Spanned), + /// Same-rank constraint. + RankSame(IdentifierList), + /// Relative-order hint. + Order(IdentifierList), +} + +impl LayoutStatement { + /// Returns the complete statement span. + pub fn span(&self) -> Span { + match self { + Self::Direction(value) => value.span, + Self::RankSame(list) | Self::Order(list) => list.span, + } + } +} + +/// A bracketed list of identifier references. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentifierList { + /// Authored identifiers in source order. + pub identifiers: Vec>, + /// Span including the list brackets. + pub span: Span, +} diff --git a/src/diagnostic.rs b/src/diagnostic.rs new file mode 100644 index 0000000..331dda9 --- /dev/null +++ b/src/diagnostic.rs @@ -0,0 +1,138 @@ +//! Structured diagnostics emitted by compiler stages. + +/// A one-based source position with its zero-based UTF-8 byte offset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SourcePosition { + /// Zero-based byte offset in the original UTF-8 source. + pub byte_offset: usize, + /// One-based source line. + pub line: usize, + /// One-based Unicode scalar column. + pub column: usize, +} + +impl SourcePosition { + /// Creates the first position in a source document. + pub const fn start() -> Self { + Self { + byte_offset: 0, + line: 1, + column: 1, + } + } +} + +/// An end-exclusive source span. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Span { + /// Inclusive start position. + pub start: SourcePosition, + /// Exclusive end position. + pub end: SourcePosition, +} + +impl Span { + /// Creates an empty span at one position. + pub const fn point(position: SourcePosition) -> Self { + Self { + start: position, + end: position, + } + } + + /// Creates a span covering both input spans. + pub fn covering(start: Self, end: Self) -> Self { + Self { + start: start.start, + end: end.end, + } + } +} + +/// A value paired with the source span that authored it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Spanned { + /// Decoded or parsed value. + pub value: T, + /// Source span for the value. + pub span: Span, +} + +impl Spanned { + /// Creates a spanned value. + pub const fn new(value: T, span: Span) -> Self { + Self { value, span } + } +} + +/// Diagnostic severity defined by the Stack specification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + /// The source cannot produce normalized IR. + Error, + /// The source remains valid but deserves attention. + Warning, +} + +/// Additional source context related to a diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelatedInformation { + /// Description of the related source location. + pub message: String, + /// Related source span. + pub span: Span, +} + +/// A portable compiler diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + /// Stable diagnostic identifier. + pub code: &'static str, + /// Error or warning severity. + pub severity: Severity, + /// Concise human-readable description. + pub message: String, + /// Primary source span. + pub span: Span, + /// Optional corrective guidance. + pub help: Option, + /// Other declarations or references involved in the problem. + pub related: Vec, +} + +impl Diagnostic { + pub(crate) fn error(code: &'static str, message: impl Into, span: Span) -> Self { + Self { + code, + severity: Severity::Error, + message: message.into(), + span, + help: None, + related: Vec::new(), + } + } + + pub(crate) fn warning(code: &'static str, message: impl Into, span: Span) -> Self { + Self { + code, + severity: Severity::Warning, + message: message.into(), + span, + help: None, + related: Vec::new(), + } + } + + pub(crate) fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + pub(crate) fn with_related(mut self, message: impl Into, span: Span) -> Self { + self.related.push(RelatedInformation { + message: message.into(), + span, + }); + self + } +} diff --git a/src/ir.rs b/src/ir.rs new file mode 100644 index 0000000..4b55ae8 --- /dev/null +++ b/src/ir.rs @@ -0,0 +1,167 @@ +//! Normalized renderer-independent diagram representation. + +/// A supported language version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LanguageVersion { + /// Language major version. + pub major: u32, + /// Language minor version. + pub minor: u32, +} + +/// A semantically valid normalized Stack diagram. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagram { + /// Declared language version. + pub language_version: LanguageVersion, + /// Visible diagram title. + pub title: String, + /// Effective theme identifier after language defaults. + pub theme_id: String, + /// Direct root children in declaration order. + pub children: Vec, + /// Nodes in declaration order. + pub nodes: Vec, + /// Groups in declaration order. + pub groups: Vec, + /// Edges in declaration order. + pub edges: Vec, + /// Optional diagram-scoped layout input. + pub layout: Option, +} + +/// A typed reference to one direct layout or containment child. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ElementId { + /// Node identifier. + Node(String), + /// Group identifier. + Group(String), +} + +impl ElementId { + /// Returns the underlying Stack identifier. + pub fn as_str(&self) -> &str { + match self { + Self::Node(identifier) | Self::Group(identifier) => identifier, + } + } +} + +/// A normalized architectural node. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Node { + /// Globally unique source identifier. + pub id: String, + /// Visible node label. + pub label: String, + /// Effective semantic node kind. + pub kind: NodeKind, + /// Optional theme-local icon identifier. + pub icon_id: Option, + /// Optional visible detail. + pub detail: Option, + /// Nearest containing group, if any. + pub parent_group_id: Option, +} + +/// Coarse architectural meaning of a node. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NodeKind { + /// Person, role, team, or autonomous participant. + Actor, + /// Browser, application, device, or other client. + Client, + /// Long-running application, API, gateway, or general component. + Service, + /// On-demand or serverless compute unit. + Function, + /// Background processor or scheduled job. + Worker, + /// Durable queryable datastore. + Database, + /// Disposable or derived datastore. + Cache, + /// Queue, stream, bus, or broker. + Queue, + /// Blob, object, file, or archival storage. + Storage, + /// System outside the architecture's control boundary. + External, +} + +/// A normalized containment group. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + /// Globally unique source identifier. + pub id: String, + /// Visible group label. + pub label: String, + /// Nearest containing group, if any. + pub parent_group_id: Option, + /// Direct children in declaration order. + pub children: Vec, + /// Optional group-scoped layout input. + pub layout: Option, +} + +/// A normalized relationship between two nodes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + /// Left or source endpoint identifier. + pub from: String, + /// Right or target endpoint identifier. + pub to: String, + /// Effective directionality. + pub direction: EdgeDirection, + /// Effective semantic relationship kind. + pub kind: EdgeKind, + /// Optional visible edge label. + pub label: Option, +} + +/// Normalized edge directionality. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EdgeDirection { + /// Directed from `from` to `to`. + Forward, + /// Symmetric in both directions. + Bidirectional, + /// Directionless or intentionally unspecified. + Association, +} + +/// Semantic relationship kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EdgeKind { + /// Generic runtime or conceptual flow. + Flow, + /// Synchronous request or call. + Request, + /// Asynchronous message or event delivery. + Event, + /// Data movement, replication, read, or write. + Data, + /// Build-time, deployment-time, or operational dependency. + Dependency, +} + +/// Normalized layout constraints and hints for one scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + /// Optional preferred flow direction. + pub direction: Option, + /// Disjoint same-rank constraints. + pub same_ranks: Vec>, + /// Optional relative-order hint. + pub order: Option>, +} + +/// Preferred layout direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Direction { + /// Prefer left-to-right progression. + Right, + /// Prefer top-to-bottom progression. + Down, +} diff --git a/src/lexer.rs b/src/lexer.rs new file mode 100644 index 0000000..9177801 --- /dev/null +++ b/src/lexer.rs @@ -0,0 +1,584 @@ +use crate::diagnostic::{Diagnostic, SourcePosition, Span}; + +type LexResult = Result>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Token { + pub(crate) kind: TokenKind, + pub(crate) span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TokenKind { + Bare(String), + String(String), + LeftBrace, + RightBrace, + LeftBracket, + RightBracket, + Comma, + Dot, + ForwardArrow, + BidirectionalArrow, + Association, + End, +} + +pub(crate) fn tokenize(source: &str) -> LexResult> { + if source.starts_with('\u{feff}') { + let start = SourcePosition::start(); + let end = SourcePosition { + byte_offset: '\u{feff}'.len_utf8(), + line: 1, + column: 2, + }; + return Err(Box::new(Diagnostic::error( + "STK1002", + "A byte order mark is not permitted.", + Span { start, end }, + ))); + } + + Lexer::new(source).tokenize() +} + +struct Lexer<'source> { + source: &'source str, + position: SourcePosition, +} + +impl<'source> Lexer<'source> { + fn new(source: &'source str) -> Self { + Self { + source, + position: SourcePosition::start(), + } + } + + fn tokenize(mut self) -> LexResult> { + let mut tokens = Vec::new(); + + loop { + self.skip_trivia(); + let start = self.position; + let Some(character) = self.peek() else { + tokens.push(Token { + kind: TokenKind::End, + span: Span::point(self.position), + }); + return Ok(tokens); + }; + + let kind = match character { + '{' => { + self.advance(); + TokenKind::LeftBrace + } + '}' => { + self.advance(); + TokenKind::RightBrace + } + '[' => { + self.advance(); + TokenKind::LeftBracket + } + ']' => { + self.advance(); + TokenKind::RightBracket + } + ',' => { + self.advance(); + TokenKind::Comma + } + '.' => { + self.advance(); + TokenKind::Dot + } + '"' => return self.lex_string(tokens), + '<' if self.remaining().starts_with("<->") => { + self.advance(); + self.advance(); + self.advance(); + TokenKind::BidirectionalArrow + } + '-' if self.remaining().starts_with("->") => { + self.advance(); + self.advance(); + TokenKind::ForwardArrow + } + '-' if self.remaining().starts_with("--") => { + self.advance(); + self.advance(); + TokenKind::Association + } + _ => self.lex_bare(), + }; + + tokens.push(Token { + kind, + span: Span { + start, + end: self.position, + }, + }); + } + } + + fn lex_string(mut self, mut tokens: Vec) -> LexResult> { + let start = self.position; + self.advance(); + let mut value = String::new(); + + loop { + let Some(character) = self.peek() else { + return Err(Box::new(Diagnostic::error( + "STK2003", + "Input ended before the string was closed.", + Span { + start, + end: self.position, + }, + ))); + }; + + match character { + '"' => { + self.advance(); + tokens.push(Token { + kind: TokenKind::String(value), + span: Span { + start, + end: self.position, + }, + }); + return self.finish_after_string(tokens); + } + '\\' => { + self.advance(); + self.lex_escape(&mut value, start)?; + } + '\n' | '\r' | '\t' => { + let span = Span { + start: self.position, + end: self.position_after_current(), + }; + return Err(Box::new(Diagnostic::error( + "STK1003", + "Strings cannot contain line breaks or tabs.", + span, + ))); + } + value_character if value_character.is_control() => { + let span = Span { + start: self.position, + end: self.position_after_current(), + }; + return Err(Box::new(Diagnostic::error( + "STK1003", + "Strings cannot contain control characters.", + span, + ))); + } + value_character => { + value.push(value_character); + self.advance(); + } + } + } + } + + fn finish_after_string(mut self, mut tokens: Vec) -> LexResult> { + loop { + self.skip_trivia(); + let start = self.position; + let Some(character) = self.peek() else { + tokens.push(Token { + kind: TokenKind::End, + span: Span::point(self.position), + }); + return Ok(tokens); + }; + + let kind = match character { + '{' => { + self.advance(); + TokenKind::LeftBrace + } + '}' => { + self.advance(); + TokenKind::RightBrace + } + '[' => { + self.advance(); + TokenKind::LeftBracket + } + ']' => { + self.advance(); + TokenKind::RightBracket + } + ',' => { + self.advance(); + TokenKind::Comma + } + '.' => { + self.advance(); + TokenKind::Dot + } + '"' => return self.lex_string(tokens), + '<' if self.remaining().starts_with("<->") => { + self.advance(); + self.advance(); + self.advance(); + TokenKind::BidirectionalArrow + } + '-' if self.remaining().starts_with("->") => { + self.advance(); + self.advance(); + TokenKind::ForwardArrow + } + '-' if self.remaining().starts_with("--") => { + self.advance(); + self.advance(); + TokenKind::Association + } + _ => self.lex_bare(), + }; + + tokens.push(Token { + kind, + span: Span { + start, + end: self.position, + }, + }); + } + } + + fn lex_escape(&mut self, value: &mut String, string_start: SourcePosition) -> LexResult<()> { + let escape_start = self.position; + let Some(character) = self.peek() else { + return Err(Box::new(Diagnostic::error( + "STK1003", + "Input ended inside a string escape.", + Span { + start: string_start, + end: self.position, + }, + ))); + }; + + match character { + '"' => { + value.push('"'); + self.advance(); + } + '\\' => { + value.push('\\'); + self.advance(); + } + 'u' => { + self.advance(); + let high_or_scalar = self.lex_code_unit(escape_start)?; + let scalar = if (0xd800..=0xdbff).contains(&high_or_scalar) { + if self.peek() != Some('\\') { + return Err(self.invalid_surrogate(escape_start)); + } + self.advance(); + if self.peek() != Some('u') { + return Err(self.invalid_surrogate(escape_start)); + } + self.advance(); + let low = self.lex_code_unit(escape_start)?; + if !(0xdc00..=0xdfff).contains(&low) { + return Err(self.invalid_surrogate(escape_start)); + } + 0x10000 + (((high_or_scalar - 0xd800) as u32) << 10) + (low - 0xdc00) as u32 + } else if (0xdc00..=0xdfff).contains(&high_or_scalar) { + return Err(self.invalid_surrogate(escape_start)); + } else { + high_or_scalar as u32 + }; + + let Some(decoded) = char::from_u32(scalar) else { + return Err(self.invalid_escape(escape_start)); + }; + if decoded.is_control() || matches!(decoded, '\n' | '\r' | '\t') { + return Err(Box::new(Diagnostic::error( + "STK1003", + "A string escape decoded to a prohibited control value.", + Span { + start: escape_start, + end: self.position, + }, + ))); + } + value.push(decoded); + } + _ => return Err(self.invalid_escape(escape_start)), + } + + Ok(()) + } + + fn lex_code_unit(&mut self, escape_start: SourcePosition) -> LexResult { + let mut digits = String::with_capacity(4); + for _ in 0..4 { + let Some(character) = self.peek() else { + return Err(self.invalid_escape(escape_start)); + }; + if !character.is_ascii_hexdigit() { + return Err(self.invalid_escape(escape_start)); + } + digits.push(character); + self.advance(); + } + + u16::from_str_radix(&digits, 16).map_err(|_| self.invalid_escape(escape_start)) + } + + fn invalid_escape(&self, start: SourcePosition) -> Box { + Box::new(Diagnostic::error( + "STK1003", + "The string contains an invalid escape.", + Span { + start, + end: self.position, + }, + )) + } + + fn invalid_surrogate(&self, start: SourcePosition) -> Box { + Box::new(Diagnostic::error( + "STK1003", + "The string contains an unpaired Unicode surrogate.", + Span { + start, + end: self.position, + }, + )) + } + + fn lex_bare(&mut self) -> TokenKind { + let start = self.position.byte_offset; + + while let Some(character) = self.peek() { + if is_trivia(character) + || matches!(character, '{' | '}' | '[' | ']' | ',' | '.' | '"') + || self.remaining().starts_with("//") + || self.remaining().starts_with("<->") + || self.remaining().starts_with("->") + || self.remaining().starts_with("--") + { + break; + } + self.advance(); + } + + if self.position.byte_offset == start { + self.advance(); + } + + TokenKind::Bare(self.source[start..self.position.byte_offset].to_owned()) + } + + fn skip_trivia(&mut self) { + loop { + while self.peek().is_some_and(is_trivia) { + self.advance(); + } + + if !self.remaining().starts_with("//") { + return; + } + + self.advance(); + self.advance(); + while self + .peek() + .is_some_and(|character| !matches!(character, '\n' | '\r')) + { + self.advance(); + } + } + } + + fn peek(&self) -> Option { + self.remaining().chars().next() + } + + fn remaining(&self) -> &'source str { + &self.source[self.position.byte_offset..] + } + + fn advance(&mut self) { + let Some(character) = self.peek() else { + return; + }; + + if character == '\r' && self.remaining().starts_with("\r\n") { + self.position.byte_offset += 2; + self.position.line += 1; + self.position.column = 1; + } else { + self.position.byte_offset += character.len_utf8(); + if matches!(character, '\n' | '\r') { + self.position.line += 1; + self.position.column = 1; + } else { + self.position.column += 1; + } + } + } + + fn position_after_current(&self) -> SourcePosition { + let mut copy = Self { + source: self.source, + position: self.position, + }; + copy.advance(); + copy.position + } +} + +fn is_trivia(character: char) -> bool { + matches!(character, ' ' | '\t' | '\n' | '\r') +} + +#[cfg(test)] +mod tests { + use super::{Lexer, Token, TokenKind, tokenize}; + + fn successful_tokens(source: &str) -> Vec { + let result = tokenize(source); + assert!(result.is_ok(), "{result:?}"); + result.into_iter().flatten().collect() + } + + #[test] + fn tokenizes_contextual_words_comments_and_operators() { + let tokens = successful_tokens("node edge \"Label\" // note\n a->b a<->b a--b"); + let kinds: Vec<_> = tokens.into_iter().map(|token| token.kind).collect(); + + assert_eq!( + kinds, + vec![ + TokenKind::Bare("node".into()), + TokenKind::Bare("edge".into()), + TokenKind::String("Label".into()), + TokenKind::Bare("a".into()), + TokenKind::ForwardArrow, + TokenKind::Bare("b".into()), + TokenKind::Bare("a".into()), + TokenKind::BidirectionalArrow, + TokenKind::Bare("b".into()), + TokenKind::Bare("a".into()), + TokenKind::Association, + TokenKind::Bare("b".into()), + TokenKind::End, + ] + ); + } + + #[test] + fn decodes_supported_string_escapes_and_surrogate_pairs() { + let tokens = successful_tokens(r#""quote: \" slash: \\ rocket: \uD83D\uDE80""#); + + assert_eq!( + tokens[0].kind, + TokenKind::String("quote: \" slash: \\ rocket: \u{1f680}".into()) + ); + } + + #[test] + fn tracks_unicode_columns_and_crlf_lines() { + let tokens = successful_tokens("node \u{65e5}\u{672c} \"x\"\r\nedge"); + + assert_eq!(tokens[1].span.start.line, 1); + assert_eq!(tokens[1].span.start.column, 6); + assert_eq!(tokens[2].span.start.column, 9); + assert_eq!(tokens[3].span.start.line, 2); + assert_eq!(tokens[3].span.start.column, 1); + } + + #[test] + fn rejects_a_byte_order_mark() { + let result = tokenize("\u{feff}stack 1.0"); + assert!(matches!(result, Err(diagnostic) if diagnostic.code == "STK1002")); + } + + #[test] + fn rejects_invalid_escapes_and_surrogates() { + for source in [r#""\n""#, r#""\uD800""#, r#""\uDC00""#, r#""\uD800\u0041""#] { + let result = tokenize(source); + assert!(matches!(result, Err(diagnostic) if diagnostic.code == "STK1003")); + } + } + + #[test] + fn reports_an_unterminated_string() { + let result = tokenize("\"unfinished"); + assert!(matches!(result, Err(diagnostic) if diagnostic.code == "STK2003")); + } + + #[test] + fn tokenizes_punctuation_before_and_after_strings() { + let tokens = successful_tokens("{}[],. \"label\" {}[],. <-> -> -- tail"); + let kinds: Vec<_> = tokens.into_iter().map(|token| token.kind).collect(); + + assert!(kinds.starts_with(&[ + TokenKind::LeftBrace, + TokenKind::RightBrace, + TokenKind::LeftBracket, + TokenKind::RightBracket, + TokenKind::Comma, + TokenKind::Dot, + ])); + assert!(kinds.contains(&TokenKind::BidirectionalArrow)); + assert!(kinds.contains(&TokenKind::ForwardArrow)); + assert!(kinds.contains(&TokenKind::Association)); + assert_eq!(kinds.last(), Some(&TokenKind::End)); + } + + #[test] + fn rejects_raw_controls_and_incomplete_escapes() { + for source in [ + "\"line\nbreak\"", + "\"tab\tvalue\"", + "\"control\u{1}value\"", + "\"unfinished\\", + r#""\u12"#, + r#""\u12""#, + r#""\uD800\x""#, + r#""\u000A""#, + ] { + let result = tokenize(source); + assert!(matches!(result, Err(diagnostic) if diagnostic.code == "STK1003")); + } + } + + #[test] + fn handles_empty_input_and_unknown_operator_prefixes() { + assert_eq!( + successful_tokens("") + .into_iter() + .map(|token| token.kind) + .collect::>(), + vec![TokenKind::End] + ); + assert_eq!( + successful_tokens("< -") + .into_iter() + .map(|token| token.kind) + .collect::>(), + vec![ + TokenKind::Bare("<".into()), + TokenKind::Bare("-".into()), + TokenKind::End, + ] + ); + + let mut lexer = Lexer::new(""); + lexer.advance(); + assert_eq!(lexer.position.byte_offset, 0); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..43d604b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,168 @@ +//! Reference compiler frontend for the Stack diagram language. +//! +//! The public pipeline deliberately stops at normalized, renderer-independent +//! diagram IR. Theme resolution, layout, and rendering belong to downstream +//! crates and applications. + +#![forbid(unsafe_code)] + +pub mod ast; +pub mod diagnostic; +pub mod ir; + +mod lexer; +mod parser; +mod validation; + +use diagnostic::Diagnostic; + +/// Output of lexical and syntax parsing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseOutput { + /// Parsed syntax tree, present only when no lexical or syntax error occurred. + pub document: Option, + /// Lexical and syntax diagnostics. + pub diagnostics: Vec, +} + +/// Output of the complete compiler frontend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompileOutput { + /// Normalized diagram, present only when no compiler-stage error occurred. + pub diagram: Option, + /// Lexical, syntax, semantic, and complexity diagnostics. + pub diagnostics: Vec, +} + +/// Parses UTF-8 Stack source into a source-oriented AST. +pub fn parse(source: &str) -> ParseOutput { + match parser::parse(source) { + Ok(document) => ParseOutput { + document: Some(document), + diagnostics: Vec::new(), + }, + Err(diagnostic) => ParseOutput { + document: None, + diagnostics: vec![*diagnostic], + }, + } +} + +/// Decodes and parses Stack source bytes into a source-oriented AST. +pub fn parse_bytes(source: &[u8]) -> ParseOutput { + match std::str::from_utf8(source) { + Ok(source) => parse(source), + Err(error) => { + let position = position_after_valid_prefix(&source[..error.valid_up_to()]); + ParseOutput { + document: None, + diagnostics: vec![Diagnostic::error( + "STK1001", + "Input is not valid UTF-8.", + diagnostic::Span::point(position), + )], + } + } + } +} + +/// Validates a parsed document and produces normalized IR when it is valid. +pub fn validate(document: &ast::Document) -> CompileOutput { + validation::validate(document) +} + +/// Parses, validates, and normalizes UTF-8 Stack source. +pub fn compile(source: &str) -> CompileOutput { + let parsed = parse(source); + match parsed.document { + Some(document) => validate(&document), + None => CompileOutput { + diagram: None, + diagnostics: parsed.diagnostics, + }, + } +} + +/// Decodes, parses, validates, and normalizes Stack source bytes. +pub fn compile_bytes(source: &[u8]) -> CompileOutput { + let parsed = parse_bytes(source); + match parsed.document { + Some(document) => validate(&document), + None => CompileOutput { + diagram: None, + diagnostics: parsed.diagnostics, + }, + } +} + +fn position_after_valid_prefix(prefix: &[u8]) -> diagnostic::SourcePosition { + let source = match std::str::from_utf8(prefix) { + Ok(source) => source, + Err(_) => return diagnostic::SourcePosition::start(), + }; + let mut position = diagnostic::SourcePosition::start(); + let mut characters = source.chars().peekable(); + + while let Some(character) = characters.next() { + position.byte_offset += character.len_utf8(); + if character == '\r' && characters.peek() == Some(&'\n') { + if let Some(newline) = characters.next() { + position.byte_offset += newline.len_utf8(); + } + position.line += 1; + position.column = 1; + } else if matches!(character, '\n' | '\r') { + position.line += 1; + position.column = 1; + } else { + position.column += 1; + } + } + + position +} + +#[cfg(test)] +mod tests { + use super::{ + compile, compile_bytes, parse, parse_bytes, position_after_valid_prefix, validate, + }; + + #[test] + fn reports_invalid_utf8_at_the_decoded_prefix_position() { + let output = parse_bytes(b"stack 1.0\ndiagram \"x\" {\n\xff}"); + + assert!(output.document.is_none()); + assert_eq!(output.diagnostics[0].code, "STK1001"); + assert_eq!(output.diagnostics[0].span.start.line, 3); + assert_eq!(output.diagnostics[0].span.start.column, 1); + } + + #[test] + fn public_entry_points_cover_success_and_syntax_failure() { + let source = "stack 1.0 diagram \"API\" { node api \"API\" }"; + let parsed = parse(source); + assert!(parsed.diagnostics.is_empty()); + let Some(document) = parsed.document else { + return; + }; + assert!(validate(&document).diagram.is_some()); + assert!(parse_bytes(source.as_bytes()).document.is_some()); + assert!(compile_bytes(source.as_bytes()).diagram.is_some()); + + let syntax_error = compile("stack 1.0 diagram \"API\" {"); + assert!(syntax_error.diagram.is_none()); + assert_eq!(syntax_error.diagnostics[0].code, "STK2003"); + } + + #[test] + fn utf8_error_positions_handle_crlf_and_defensive_invalid_prefixes() { + let output = parse_bytes(b"stack 1.0\r\n\xff"); + assert_eq!(output.diagnostics[0].span.start.line, 2); + assert_eq!(output.diagnostics[0].span.start.column, 1); + assert_eq!( + position_after_valid_prefix(b"\xff"), + crate::diagnostic::SourcePosition::start() + ); + } +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..4904d71 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,688 @@ +use crate::ast::{ + Diagram, DiagramMember, Document, Edge, EdgeOperator, EdgeProperty, Group, GroupMember, + IdentifierList, Layout, LayoutStatement, Node, NodeProperty, Theme, Version, +}; +use crate::diagnostic::{Diagnostic, Span, Spanned}; +use crate::lexer::{Token, TokenKind, tokenize}; + +type ParseResult = Result>; + +pub(crate) fn parse(source: &str) -> ParseResult { + Parser::new(tokenize(source)?).parse_document() +} + +struct Parser { + tokens: Vec, + current: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { tokens, current: 0 } + } + + fn parse_document(mut self) -> ParseResult { + let version = self.parse_version()?; + let diagram = self.parse_diagram()?; + self.expect_end()?; + + Ok(Document { + span: Span::covering(version.span, diagram.span), + version, + diagram, + }) + } + + fn parse_version(&mut self) -> ParseResult { + let start = self.expect_keyword("stack")?.span; + let (major, _) = self.expect_integer("language major version")?; + self.expect_simple(|kind| matches!(kind, TokenKind::Dot), "'.'")?; + let (minor, minor_span) = self.expect_integer("language minor version")?; + + Ok(Version { + major, + minor, + span: Span::covering(start, minor_span), + }) + } + + fn parse_diagram(&mut self) -> ParseResult { + let start = self.expect_keyword("diagram")?.span; + let title = self.expect_string("diagram title")?; + self.expect_simple(|kind| matches!(kind, TokenKind::LeftBrace), "'{'")?; + + let mut members = Vec::new(); + while !self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + self.reject_end("diagram")?; + members.push(match self.current_bare() { + Some("node") => DiagramMember::Node(self.parse_node()?), + Some("group") => DiagramMember::Group(self.parse_group()?), + Some("edge") => DiagramMember::Edge(self.parse_edge()?), + Some("theme") => DiagramMember::Theme(self.parse_theme()?), + Some("layout") => DiagramMember::Layout(self.parse_layout()?), + _ => return Err(self.unexpected("a diagram declaration")), + }); + } + + let end = self + .expect_simple(|kind| matches!(kind, TokenKind::RightBrace), "'}'")? + .span; + + Ok(Diagram { + title, + members, + span: Span::covering(start, end), + }) + } + + fn parse_group(&mut self) -> ParseResult { + let start = self.expect_keyword("group")?.span; + let identifier = self.expect_identifier("group identifier")?; + let label = self.expect_string("group label")?; + self.expect_simple(|kind| matches!(kind, TokenKind::LeftBrace), "'{'")?; + + let mut members = Vec::new(); + while !self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + self.reject_end("group")?; + members.push(match self.current_bare() { + Some("node") => GroupMember::Node(self.parse_node()?), + Some("group") => GroupMember::Group(self.parse_group()?), + Some("layout") => GroupMember::Layout(self.parse_layout()?), + _ => return Err(self.unexpected("a node, group, or layout declaration")), + }); + } + + let end = self + .expect_simple(|kind| matches!(kind, TokenKind::RightBrace), "'}'")? + .span; + + Ok(Group { + identifier, + label, + members, + span: Span::covering(start, end), + }) + } + + fn parse_node(&mut self) -> ParseResult { + let start = self.expect_keyword("node")?.span; + let identifier = self.expect_identifier("node identifier")?; + let label = self.expect_string("node label")?; + let mut end = label.span; + let mut properties = Vec::new(); + + if self + .take_simple(|kind| matches!(kind, TokenKind::LeftBrace)) + .is_some() + { + if self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + return Err(self.unexpected("at least one node property")); + } + + while !self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + self.reject_end("node block")?; + properties.push(match self.current_bare() { + Some("kind") => { + self.advance(); + NodeProperty::Kind(self.expect_identifier("node kind")?) + } + Some("icon") => { + self.advance(); + NodeProperty::Icon(self.expect_string("icon identifier")?) + } + Some("detail") => { + self.advance(); + NodeProperty::Detail(self.expect_string("node detail")?) + } + _ => return Err(self.unexpected("a node property")), + }); + } + + end = self + .expect_simple(|kind| matches!(kind, TokenKind::RightBrace), "'}'")? + .span; + } + + Ok(Node { + identifier, + label, + properties, + span: Span::covering(start, end), + }) + } + + fn parse_edge(&mut self) -> ParseResult { + let start = self.expect_keyword("edge")?.span; + let from = self.expect_identifier("edge endpoint")?; + let operator_token = self.current_token().clone(); + let operator = match operator_token.kind { + TokenKind::ForwardArrow => EdgeOperator::Forward, + TokenKind::BidirectionalArrow => EdgeOperator::Bidirectional, + TokenKind::Association => EdgeOperator::Association, + _ => return Err(self.unexpected("an edge operator")), + }; + self.advance(); + let operator = Spanned::new(operator, operator_token.span); + let to = self.expect_identifier("edge endpoint")?; + let label = if matches!(self.current_token().kind, TokenKind::String(_)) { + Some(self.expect_string("edge label")?) + } else { + None + }; + + let mut end = label.as_ref().map_or(to.span, |label| label.span); + let mut properties = Vec::new(); + if self + .take_simple(|kind| matches!(kind, TokenKind::LeftBrace)) + .is_some() + { + if self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + return Err(self.unexpected("at least one edge property")); + } + + while !self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + self.reject_end("edge block")?; + properties.push(match self.current_bare() { + Some("kind") => { + self.advance(); + EdgeProperty::Kind(self.expect_identifier("edge kind")?) + } + _ => return Err(self.unexpected("an edge property")), + }); + } + + end = self + .expect_simple(|kind| matches!(kind, TokenKind::RightBrace), "'}'")? + .span; + } + + Ok(Edge { + from, + operator, + to, + label, + properties, + span: Span::covering(start, end), + }) + } + + fn parse_theme(&mut self) -> ParseResult { + let start = self.expect_keyword("theme")?.span; + let identifier = self.expect_identifier("theme identifier")?; + Ok(Theme { + span: Span::covering(start, identifier.span), + identifier, + }) + } + + fn parse_layout(&mut self) -> ParseResult { + let start = self.expect_keyword("layout")?.span; + self.expect_simple(|kind| matches!(kind, TokenKind::LeftBrace), "'{'")?; + if self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + return Err(self.unexpected("at least one layout statement")); + } + + let mut statements = Vec::new(); + while !self.at_simple(|kind| matches!(kind, TokenKind::RightBrace)) { + self.reject_end("layout block")?; + statements.push(match self.current_bare() { + Some("direction") => { + self.advance(); + LayoutStatement::Direction(self.expect_identifier("layout direction")?) + } + Some("rank") => { + self.advance(); + self.expect_keyword("same")?; + LayoutStatement::RankSame(self.parse_identifier_list()?) + } + Some("order") => { + self.advance(); + LayoutStatement::Order(self.parse_identifier_list()?) + } + _ => return Err(self.unexpected("a layout statement")), + }); + } + + let end = self + .expect_simple(|kind| matches!(kind, TokenKind::RightBrace), "'}'")? + .span; + Ok(Layout { + statements, + span: Span::covering(start, end), + }) + } + + fn parse_identifier_list(&mut self) -> ParseResult { + let start = self + .expect_simple(|kind| matches!(kind, TokenKind::LeftBracket), "'['")? + .span; + let mut identifiers = vec![self.expect_identifier("layout identifier")?]; + self.expect_simple(|kind| matches!(kind, TokenKind::Comma), "','")?; + identifiers.push(self.expect_identifier("layout identifier")?); + + while self + .take_simple(|kind| matches!(kind, TokenKind::Comma)) + .is_some() + { + identifiers.push(self.expect_identifier("layout identifier")?); + } + + let end = self + .expect_simple(|kind| matches!(kind, TokenKind::RightBracket), "']'")? + .span; + Ok(IdentifierList { + identifiers, + span: Span::covering(start, end), + }) + } + + fn expect_integer(&mut self, description: &str) -> ParseResult<(u32, Span)> { + let value = self.expect_identifier(description)?; + if value.value.len() > 1 && value.value.starts_with('0') { + return Err(Box::new(Diagnostic::error( + "STK2002", + format!("Expected {description} without leading zeroes."), + value.span, + ))); + } + + let parsed = value.value.parse::().map_err(|_| { + Box::new(Diagnostic::error( + "STK2002", + format!("Expected {description}."), + value.span, + )) + })?; + Ok((parsed, value.span)) + } + + fn expect_identifier(&mut self, description: &str) -> ParseResult> { + let token = self.current_token().clone(); + match token.kind { + TokenKind::Bare(value) => { + self.advance(); + Ok(Spanned::new(value, token.span)) + } + _ => Err(self.unexpected(description)), + } + } + + fn expect_string(&mut self, description: &str) -> ParseResult> { + let token = self.current_token().clone(); + match token.kind { + TokenKind::String(value) => { + self.advance(); + Ok(Spanned::new(value, token.span)) + } + _ => Err(self.unexpected(description)), + } + } + + fn expect_keyword(&mut self, keyword: &str) -> ParseResult { + if self.current_bare() != Some(keyword) { + return Err(self.unexpected(&format!("'{keyword}'"))); + } + let token = self.current_token().clone(); + self.advance(); + Ok(token) + } + + fn expect_simple( + &mut self, + predicate: impl FnOnce(&TokenKind) -> bool, + description: &str, + ) -> ParseResult { + if !predicate(&self.current_token().kind) { + return Err(self.unexpected(description)); + } + let token = self.current_token().clone(); + self.advance(); + Ok(token) + } + + fn take_simple(&mut self, predicate: impl FnOnce(&TokenKind) -> bool) -> Option { + if !predicate(&self.current_token().kind) { + return None; + } + let token = self.current_token().clone(); + self.advance(); + Some(token) + } + + fn expect_end(&self) -> ParseResult<()> { + if matches!(self.current_token().kind, TokenKind::End) { + Ok(()) + } else { + Err(self.unexpected("the end of the document")) + } + } + + fn reject_end(&self, construct: &str) -> ParseResult<()> { + if matches!(self.current_token().kind, TokenKind::End) { + Err(Box::new(Diagnostic::error( + "STK2003", + format!("Input ended before the {construct} was complete."), + self.current_token().span, + ))) + } else { + Ok(()) + } + } + + fn unexpected(&self, expected: &str) -> Box { + if matches!(self.current_token().kind, TokenKind::End) { + Box::new(Diagnostic::error( + "STK2003", + format!("Input ended while expecting {expected}."), + self.current_token().span, + )) + } else { + Box::new(Diagnostic::error( + "STK2002", + format!("Expected {expected}."), + self.current_token().span, + )) + } + } + + fn current_bare(&self) -> Option<&str> { + match &self.current_token().kind { + TokenKind::Bare(value) => Some(value), + _ => None, + } + } + + fn at_simple(&self, predicate: impl FnOnce(&TokenKind) -> bool) -> bool { + predicate(&self.current_token().kind) + } + + fn current_token(&self) -> &Token { + &self.tokens[self.current] + } + + fn advance(&mut self) { + if self.current + 1 < self.tokens.len() { + self.current += 1; + } + } +} + +#[cfg(test)] +mod tests { + use crate::ast::{DiagramMember, EdgeOperator, GroupMember, LayoutStatement}; + use crate::lexer::{Token, tokenize}; + + use super::{Parser, parse}; + + fn successful_tokens(source: &str) -> Vec { + let result = tokenize(source); + assert!(result.is_ok(), "{result:?}"); + result.into_iter().flatten().collect() + } + + #[test] + fn parses_a_minimal_document() { + let result = parse( + r#"stack 1.0 +diagram "Hello Stack" { + node web "Web app" + node api "API" + edge web -> api +}"#, + ); + assert!(result.is_ok(), "{result:?}"); + let Some(document) = result.ok() else { + return; + }; + + assert_eq!(document.version.major, 1); + assert_eq!(document.version.minor, 0); + assert_eq!(document.diagram.title.value, "Hello Stack"); + assert_eq!(document.diagram.members.len(), 3); + assert!(matches!( + &document.diagram.members[2], + DiagramMember::Edge(edge) if edge.operator.value == EdgeOperator::Forward + )); + } + + #[test] + fn parses_groups_properties_and_layout() { + let result = parse( + r#"stack 1.0 +diagram "System" { + theme dark + group group "Group" { + layout { + direction down + rank same [node, service] + order [node, service] + } + node node "Node" { kind client icon "browser" detail "UI" } + node service "Service" + } + edge node <-> service "RPC" { kind request } +}"#, + ); + assert!(result.is_ok(), "{result:?}"); + let Some(document) = result.ok() else { + return; + }; + + assert!(matches!( + &document.diagram.members[1], + DiagramMember::Group(group) + if group.identifier.value == "group" + && matches!( + &group.members[0], + GroupMember::Layout(layout) + if matches!(layout.statements[1], LayoutStatement::RankSame(_)) + ) + )); + } + + #[test] + fn rejects_unknown_and_incomplete_syntax() { + let unknown = parse("stack 1.0 diagram \"x\" { server api \"API\" }"); + assert!(matches!(unknown, Err(diagnostic) if diagnostic.code == "STK2002")); + + let incomplete = parse("stack 1.0 diagram \"x\" { node api \"API\""); + assert!(matches!(incomplete, Err(diagnostic) if diagnostic.code == "STK2003")); + } + + #[test] + fn requires_nonempty_property_and_layout_blocks() { + for source in [ + "stack 1.0 diagram \"x\" { node api \"API\" {} }", + "stack 1.0 diagram \"x\" { node a \"A\" node b \"B\" edge a -> b {} }", + "stack 1.0 diagram \"x\" { node api \"API\" layout {} }", + ] { + assert!(matches!( + parse(source), + Err(diagnostic) if diagnostic.code == "STK2002" + )); + } + } + + #[test] + fn parses_all_edge_operators_and_long_layout_lists() { + let result = parse( + r#"stack 1.0 +diagram "Operators" { + node a "A" + node b "B" + node c "C" + layout { order [a, b, c] } + edge a -> b + edge a <-> c + edge b -- c +}"#, + ); + + assert!(result.is_ok(), "{result:?}"); + let Some(document) = result.ok() else { + return; + }; + let operators: Vec<_> = document + .diagram + .members + .iter() + .filter_map(|member| match member { + DiagramMember::Edge(edge) => Some(edge.operator.value), + _ => None, + }) + .collect(); + assert_eq!( + operators, + vec![ + EdgeOperator::Forward, + EdgeOperator::Bidirectional, + EdgeOperator::Association, + ] + ); + } + + #[test] + fn reports_errors_for_each_recursive_descent_boundary() { + let cases = [ + ("", "STK2003"), + ("diagram \"x\" {}", "STK2002"), + ("stack 01.0 diagram \"x\" {}", "STK2002"), + ("stack x.0 diagram \"x\" {}", "STK2002"), + ("stack 1.x diagram \"x\" {}", "STK2002"), + ("stack 1 0 diagram \"x\" {}", "STK2002"), + ("stack 1.0 other \"x\" {}", "STK2002"), + ("stack 1.0 diagram x {}", "STK2002"), + ("stack 1.0 diagram \"x\" other", "STK2002"), + ("stack 1.0 diagram \"x\" {} trailing", "STK2002"), + ("stack 1.0 diagram \"x\" { group \"G\" {} }", "STK2002"), + ("stack 1.0 diagram \"x\" { group g label {} }", "STK2002"), + ("stack 1.0 diagram \"x\" { group g \"G\" other }", "STK2002"), + ("stack 1.0 diagram \"x\" { group g \"G\" {", "STK2003"), + ( + "stack 1.0 diagram \"x\" { group g \"G\" { edge a -> b } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { group g \"G\" { node } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { group g \"G\" { group } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { group g \"G\" { layout } }", + "STK2002", + ), + ("stack 1.0 diagram \"x\" { node \"a\" \"A\" }", "STK2002"), + ("stack 1.0 diagram \"x\" { node a label }", "STK2002"), + ( + "stack 1.0 diagram \"x\" { node a \"A\" { unknown value } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" { kind } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" { icon value } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" { detail value } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" { kind service", + "STK2003", + ), + ("stack 1.0 diagram \"x\" { edge \"a\" -> b }", "STK2002"), + ( + "stack 1.0 diagram \"x\" { node a \"A\" node b \"B\" edge a ? b }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" edge a -> }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" node b \"B\" edge a -> b { unknown value } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" node b \"B\" edge a -> b { kind } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" node b \"B\" edge a -> b { kind flow", + "STK2003", + ), + ("stack 1.0 diagram \"x\" { theme }", "STK2002"), + ("stack 1.0 diagram \"x\" { layout other }", "STK2002"), + ("stack 1.0 diagram \"x\" { layout {", "STK2003"), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { unknown value } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { direction } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { rank other [a, b] } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { order other } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { rank same [a] } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { order [, a] } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { order [a b] } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { order [a, ] } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" layout { order [a, b,] } }", + "STK2002", + ), + ( + "stack 1.0 diagram \"x\" { node a \"A\" node b \"B\" layout { order [a, b } }", + "STK2002", + ), + ]; + + for (source, expected_code) in cases { + let result = parse(source); + assert!( + matches!(result, Err(diagnostic) if diagnostic.code == expected_code), + "source unexpectedly parsed: {source}" + ); + } + } + + #[test] + fn production_parsers_reject_the_wrong_entry_keyword() { + let tokens = successful_tokens("wrong"); + + assert!(Parser::new(tokens.clone()).parse_group().is_err()); + assert!(Parser::new(tokens.clone()).parse_node().is_err()); + assert!(Parser::new(tokens.clone()).parse_edge().is_err()); + assert!(Parser::new(tokens.clone()).parse_theme().is_err()); + assert!(Parser::new(tokens).parse_layout().is_err()); + + let mut end_parser = Parser::new(successful_tokens("")); + end_parser.advance(); + assert_eq!(end_parser.current, 0); + } +} diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..efbb8d5 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,996 @@ +use std::collections::HashMap; + +use crate::CompileOutput; +use crate::ast::{self, DiagramMember, GroupMember, LayoutStatement, NodeProperty}; +use crate::diagnostic::{Diagnostic, Severity, Span, Spanned}; +use crate::ir; + +const SUPPORTED_MAJOR: u32 = 1; +const SUPPORTED_MINOR: u32 = 0; + +pub(crate) fn validate(document: &ast::Document) -> CompileOutput { + let mut validator = Validator::new(document); + validator.run(); + validator.diagnostics.sort_by(|left, right| { + left.span + .start + .byte_offset + .cmp(&right.span.start.byte_offset) + .then_with(|| severity_order(left.severity).cmp(&severity_order(right.severity))) + .then_with(|| left.code.cmp(right.code)) + .then_with(|| left.message.cmp(&right.message)) + }); + + let has_errors = validator + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error); + CompileOutput { + diagram: (!has_errors).then(|| normalize(document)), + diagnostics: validator.diagnostics, + } +} + +fn severity_order(severity: Severity) -> u8 { + match severity { + Severity::Error => 0, + Severity::Warning => 1, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SymbolKind { + Node, + Group, +} + +#[derive(Debug, Clone, Copy)] +struct Symbol { + kind: SymbolKind, + span: Span, +} + +struct Validator<'document> { + document: &'document ast::Document, + diagnostics: Vec, + symbols: HashMap<&'document str, Symbol>, + node_count: usize, + group_count: usize, +} + +impl<'document> Validator<'document> { + fn new(document: &'document ast::Document) -> Self { + Self { + document, + diagnostics: Vec::new(), + symbols: HashMap::new(), + node_count: 0, + group_count: 0, + } + } + + fn run(&mut self) { + self.validate_version(); + self.validate_text(&self.document.diagram.title, 1, 80, "diagram title"); + self.collect_declarations(); + self.validate_themes(); + self.validate_layout_scopes(); + self.validate_edges(); + self.validate_complexity(); + } + + fn validate_version(&mut self) { + let version = &self.document.version; + if version.major != SUPPORTED_MAJOR || version.minor > SUPPORTED_MINOR { + self.diagnostics.push( + Diagnostic::error( + "STK2001", + format!( + "Stack {}.{} is not supported by this compiler.", + version.major, version.minor + ), + version.span, + ) + .with_help(format!( + "Use Stack {SUPPORTED_MAJOR}.{SUPPORTED_MINOR} or an older compatible minor version." + )), + ); + } + } + + fn collect_declarations(&mut self) { + for member in &self.document.diagram.members { + match member { + DiagramMember::Node(node) => self.collect_node(node), + DiagramMember::Group(group) => self.collect_group(group, 1), + DiagramMember::Edge(_) | DiagramMember::Theme(_) | DiagramMember::Layout(_) => {} + } + } + } + + fn collect_node(&mut self, node: &'document ast::Node) { + self.node_count += 1; + self.validate_identifier(&node.identifier); + self.declare(&node.identifier, SymbolKind::Node); + self.validate_text(&node.label, 1, 60, "node label"); + self.validate_node_properties(node); + } + + fn collect_group(&mut self, group: &'document ast::Group, depth: usize) { + self.group_count += 1; + self.validate_identifier(&group.identifier); + self.declare(&group.identifier, SymbolKind::Group); + self.validate_text(&group.label, 1, 60, "group label"); + + if depth > 3 { + self.diagnostics.push(Diagnostic::error( + "STK3010", + "Group nesting exceeds three levels below the diagram.", + group.identifier.span, + )); + } + + if descendant_node_count(group) == 0 { + self.diagnostics.push( + Diagnostic::error( + "STK3009", + format!( + "Group '{}' does not contain a descendant node.", + group.identifier.value + ), + group.identifier.span, + ) + .with_help("Add a node to this group or remove the empty boundary."), + ); + } + + for member in &group.members { + match member { + GroupMember::Node(node) => self.collect_node(node), + GroupMember::Group(child) => self.collect_group(child, depth + 1), + GroupMember::Layout(_) => {} + } + } + } + + fn declare(&mut self, identifier: &'document Spanned, kind: SymbolKind) { + if let Some(original) = self.symbols.get(identifier.value.as_str()).copied() { + self.diagnostics.push( + Diagnostic::error( + "STK3002", + format!( + "Identifier '{}' is declared more than once.", + identifier.value + ), + identifier.span, + ) + .with_related("The first declaration is here.", original.span), + ); + } else { + self.symbols.insert( + identifier.value.as_str(), + Symbol { + kind, + span: identifier.span, + }, + ); + } + } + + fn validate_node_properties(&mut self, node: &ast::Node) { + let mut seen = HashMap::new(); + for property in &node.properties { + let (name, value) = match property { + NodeProperty::Kind(value) => ("kind", value), + NodeProperty::Icon(value) => ("icon", value), + NodeProperty::Detail(value) => ("detail", value), + }; + self.reject_duplicate_property(name, property.span(), &mut seen); + + match property { + NodeProperty::Kind(value) => { + self.validate_identifier(value); + if parse_node_kind(&value.value).is_none() { + self.diagnostics.push(Diagnostic::error( + "STK2002", + format!("Unknown node kind '{}'.", value.value), + value.span, + )); + } + } + NodeProperty::Icon(value) => self.validate_icon_identifier(value), + NodeProperty::Detail(value) => self.validate_text(value, 1, 80, "node detail"), + } + + let _ = value; + } + } + + fn validate_themes(&mut self) { + let mut first = None; + for member in &self.document.diagram.members { + let DiagramMember::Theme(theme) = member else { + continue; + }; + self.validate_identifier(&theme.identifier); + if let Some(first_span) = first { + self.diagnostics.push( + Diagnostic::error( + "STK3014", + "A diagram may contain only one theme statement.", + theme.span, + ) + .with_related("The first theme statement is here.", first_span), + ); + } else { + first = Some(theme.span); + } + } + } + + fn validate_layout_scopes(&mut self) { + let root_children = direct_diagram_children(&self.document.diagram); + let root_layouts: Vec<_> = self + .document + .diagram + .members + .iter() + .filter_map(|member| match member { + DiagramMember::Layout(layout) => Some(layout), + _ => None, + }) + .collect(); + self.validate_layout_blocks(&root_layouts, &root_children); + + for member in &self.document.diagram.members { + if let DiagramMember::Group(group) = member { + self.validate_group_layout_scopes(group); + } + } + } + + fn validate_group_layout_scopes(&mut self, group: &ast::Group) { + let children = direct_group_children(group); + let layouts: Vec<_> = group + .members + .iter() + .filter_map(|member| match member { + GroupMember::Layout(layout) => Some(layout), + _ => None, + }) + .collect(); + self.validate_layout_blocks(&layouts, &children); + + for member in &group.members { + if let GroupMember::Group(child) = member { + self.validate_group_layout_scopes(child); + } + } + } + + fn validate_layout_blocks( + &mut self, + layouts: &[&ast::Layout], + direct_children: &HashMap<&str, Span>, + ) { + if let Some((first, rest)) = layouts.split_first() { + for duplicate in rest { + self.diagnostics.push( + Diagnostic::error( + "STK3012", + "A layout scope may contain only one layout block.", + duplicate.span, + ) + .with_related("The first layout block is here.", first.span), + ); + } + } + + for layout in layouts { + self.validate_layout(layout, direct_children); + } + } + + fn validate_layout(&mut self, layout: &ast::Layout, direct_children: &HashMap<&str, Span>) { + let mut first_direction = None; + let mut first_order = None; + let mut ranked_children = HashMap::new(); + + for statement in &layout.statements { + match statement { + LayoutStatement::Direction(value) => { + self.validate_identifier(value); + if !matches!(value.value.as_str(), "right" | "down") { + self.diagnostics.push(Diagnostic::error( + "STK2002", + format!("Unknown layout direction '{}'.", value.value), + value.span, + )); + } + self.reject_duplicate_singleton( + "direction statement", + statement.span(), + &mut first_direction, + ); + } + LayoutStatement::RankSame(list) => { + self.validate_layout_list(list, direct_children); + for identifier in &list.identifiers { + if let Some(original) = ranked_children.get(identifier.value.as_str()) { + self.diagnostics.push( + Diagnostic::error( + "STK3011", + format!( + "Layout child '{}' occurs in more than one same-rank statement.", + identifier.value + ), + identifier.span, + ) + .with_related("The child was first ranked here.", *original), + ); + } else { + ranked_children.insert(identifier.value.as_str(), identifier.span); + } + } + } + LayoutStatement::Order(list) => { + self.validate_layout_list(list, direct_children); + self.reject_duplicate_singleton( + "order statement", + statement.span(), + &mut first_order, + ); + } + } + } + } + + fn validate_layout_list( + &mut self, + list: &ast::IdentifierList, + direct_children: &HashMap<&str, Span>, + ) { + let mut seen = HashMap::new(); + for identifier in &list.identifiers { + let identifier_is_valid = self.validate_identifier(identifier); + if identifier_is_valid && !direct_children.contains_key(identifier.value.as_str()) { + self.diagnostics.push( + Diagnostic::error( + "STK3011", + format!( + "Layout reference '{}' is not a direct child of this scope.", + identifier.value + ), + identifier.span, + ) + .with_help("Reference a node or group declared directly in this layout scope."), + ); + } + + if let Some(original) = seen.insert(identifier.value.as_str(), identifier.span) { + self.diagnostics.push( + Diagnostic::error( + "STK3011", + format!( + "Layout reference '{}' occurs more than once in the same list.", + identifier.value + ), + identifier.span, + ) + .with_related("The first occurrence is here.", original), + ); + } + } + } + + fn validate_edges(&mut self) { + let mut duplicate_edges = HashMap::new(); + let mut degree: HashMap<&str, usize> = HashMap::new(); + + for member in &self.document.diagram.members { + let DiagramMember::Edge(edge) = member else { + continue; + }; + + let from_is_node = self.validate_edge_endpoint(&edge.from); + let to_is_node = self.validate_edge_endpoint(&edge.to); + self.validate_optional_text(edge.label.as_ref(), 1, 40, "edge label"); + let edge_kind = self.validate_edge_properties(edge); + + if from_is_node { + *degree.entry(edge.from.value.as_str()).or_default() += 1; + } + if to_is_node && edge.to.value != edge.from.value { + *degree.entry(edge.to.value.as_str()).or_default() += 1; + } + + if from_is_node && to_is_node && edge.from.value == edge.to.value { + self.diagnostics.push(Diagnostic::error( + "STK3005", + format!("Edge connects node '{}' to itself.", edge.from.value), + edge.span, + )); + } + + if let Some(edge_kind) = + edge_kind.filter(|_| from_is_node && to_is_node && edge.from.value != edge.to.value) + { + let key = edge_key(edge, edge_kind); + if let Some(original) = duplicate_edges.insert(key, edge.span) { + self.diagnostics.push( + Diagnostic::error( + "STK3006", + "An exact duplicate edge is declared.", + edge.span, + ) + .with_related("The first edge is here.", original), + ); + } + } + } + + self.warn_dense_nodes(°ree); + } + + fn validate_edge_endpoint(&mut self, endpoint: &Spanned) -> bool { + if !self.validate_identifier(endpoint) { + return false; + } + + match self.symbols.get(endpoint.value.as_str()).copied() { + Some(Symbol { + kind: SymbolKind::Node, + .. + }) => true, + Some(Symbol { + kind: SymbolKind::Group, + .. + }) => { + self.diagnostics.push( + Diagnostic::error( + "STK3004", + format!( + "Group '{}' cannot be used as an edge endpoint.", + endpoint.value + ), + endpoint.span, + ) + .with_help("Connect the participating node inside the group."), + ); + false + } + None => { + self.diagnostics.push(Diagnostic::error( + "STK3003", + format!("Unknown node '{}'.", endpoint.value), + endpoint.span, + )); + false + } + } + } + + fn validate_edge_properties(&mut self, edge: &ast::Edge) -> Option<&'static str> { + let mut seen = HashMap::new(); + let mut effective = Some("flow"); + for property in &edge.properties { + let ast::EdgeProperty::Kind(value) = property; + self.reject_duplicate_property("kind", property.span(), &mut seen); + self.validate_identifier(value); + if let Some(kind) = parse_edge_kind(&value.value) { + effective = Some(kind.as_str()); + } else { + self.diagnostics.push(Diagnostic::error( + "STK2002", + format!("Unknown edge kind '{}'.", value.value), + value.span, + )); + effective = None; + } + } + effective + } + + fn warn_dense_nodes(&mut self, degree: &HashMap<&str, usize>) { + visit_nodes(&self.document.diagram, &mut |node| { + if let Some(&count) = degree.get(node.identifier.value.as_str()) { + if count > 12 { + self.diagnostics.push( + Diagnostic::warning( + "STK4002", + format!( + "Node '{}' has {count} incident edges; more than 12 may reduce legibility.", + node.identifier.value + ), + node.identifier.span, + ) + .with_help("Consider splitting the diagram into more focused views."), + ); + } + } + }); + } + + fn validate_complexity(&mut self) { + let edge_count = self + .document + .diagram + .members + .iter() + .filter(|member| matches!(member, DiagramMember::Edge(_))) + .count(); + if !(1..=40).contains(&self.node_count) { + self.diagnostics.push(Diagnostic::error( + "STK4003", + format!( + "A diagram must contain between 1 and 40 nodes; found {}.", + self.node_count + ), + self.document.diagram.span, + )); + } + if self.group_count > 12 { + self.diagnostics.push(Diagnostic::error( + "STK4003", + format!( + "A diagram may contain at most 12 groups; found {}.", + self.group_count + ), + self.document.diagram.span, + )); + } + + let maximum_edges = 80.min(self.node_count.saturating_mul(2)); + if edge_count > maximum_edges { + self.diagnostics.push(Diagnostic::error( + "STK4003", + format!( + "A diagram with {} nodes may contain at most {maximum_edges} edges; found {edge_count}.", + self.node_count + ), + self.document.diagram.span, + )); + } + } + + fn validate_identifier(&mut self, identifier: &Spanned) -> bool { + if is_identifier(&identifier.value) { + true + } else { + self.diagnostics.push(Diagnostic::error( + "STK3001", + format!("Identifier '{}' is invalid.", identifier.value), + identifier.span, + )); + false + } + } + + fn validate_icon_identifier(&mut self, identifier: &Spanned) { + if !is_icon_identifier(&identifier.value) { + self.diagnostics.push(Diagnostic::error( + "STK3013", + format!("Icon identifier '{}' is malformed.", identifier.value), + identifier.span, + )); + } + } + + fn validate_optional_text( + &mut self, + value: Option<&Spanned>, + minimum: usize, + maximum: usize, + description: &str, + ) { + if let Some(value) = value { + self.validate_text(value, minimum, maximum, description); + } + } + + fn validate_text( + &mut self, + value: &Spanned, + minimum: usize, + maximum: usize, + description: &str, + ) { + let length = value.value.chars().count(); + let boundary_whitespace = value.value.chars().next().is_some_and(char::is_whitespace) + || value + .value + .chars() + .next_back() + .is_some_and(char::is_whitespace); + if !(minimum..=maximum).contains(&length) || boundary_whitespace { + self.diagnostics.push(Diagnostic::error( + "STK3008", + format!( + "The {description} must contain {minimum} to {maximum} Unicode scalar values without leading or trailing whitespace." + ), + value.span, + )); + } + } + + fn reject_duplicate_property( + &mut self, + name: &'static str, + span: Span, + seen: &mut HashMap<&'static str, Span>, + ) { + if let Some(original) = seen.insert(name, span) { + self.diagnostics.push( + Diagnostic::error( + "STK3007", + format!("Property '{name}' occurs more than once in the same block."), + span, + ) + .with_related("The first property is here.", original), + ); + } + } + + fn reject_duplicate_singleton( + &mut self, + description: &str, + span: Span, + first: &mut Option, + ) { + if let Some(original) = first { + self.diagnostics.push( + Diagnostic::error( + "STK3012", + format!("A layout block may contain only one {description}."), + span, + ) + .with_related("The first occurrence is here.", *original), + ); + } else { + *first = Some(span); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct EdgeKey { + from: String, + to: String, + operator: ast::EdgeOperator, + label: Option, + kind: &'static str, +} + +fn edge_key(edge: &ast::Edge, kind: &'static str) -> EdgeKey { + let (from, to) = match edge.operator.value { + ast::EdgeOperator::Forward => (edge.from.value.clone(), edge.to.value.clone()), + ast::EdgeOperator::Bidirectional | ast::EdgeOperator::Association => { + if edge.from.value <= edge.to.value { + (edge.from.value.clone(), edge.to.value.clone()) + } else { + (edge.to.value.clone(), edge.from.value.clone()) + } + } + }; + EdgeKey { + from, + to, + operator: edge.operator.value, + label: edge.label.as_ref().map(|label| label.value.clone()), + kind, + } +} + +fn is_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_lowercase() + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} + +fn is_icon_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') +} + +fn parse_node_kind(value: &str) -> Option { + Some(match value { + "actor" => ir::NodeKind::Actor, + "client" => ir::NodeKind::Client, + "service" => ir::NodeKind::Service, + "function" => ir::NodeKind::Function, + "worker" => ir::NodeKind::Worker, + "database" => ir::NodeKind::Database, + "cache" => ir::NodeKind::Cache, + "queue" => ir::NodeKind::Queue, + "storage" => ir::NodeKind::Storage, + "external" => ir::NodeKind::External, + _ => return None, + }) +} + +fn parse_edge_kind(value: &str) -> Option { + Some(match value { + "flow" => ir::EdgeKind::Flow, + "request" => ir::EdgeKind::Request, + "event" => ir::EdgeKind::Event, + "data" => ir::EdgeKind::Data, + "dependency" => ir::EdgeKind::Dependency, + _ => return None, + }) +} + +impl ir::EdgeKind { + fn as_str(self) -> &'static str { + match self { + Self::Flow => "flow", + Self::Request => "request", + Self::Event => "event", + Self::Data => "data", + Self::Dependency => "dependency", + } + } +} + +fn descendant_node_count(group: &ast::Group) -> usize { + group + .members + .iter() + .map(|member| match member { + GroupMember::Node(_) => 1, + GroupMember::Group(group) => descendant_node_count(group), + GroupMember::Layout(_) => 0, + }) + .sum() +} + +fn direct_diagram_children(diagram: &ast::Diagram) -> HashMap<&str, Span> { + diagram + .members + .iter() + .filter_map(|member| match member { + DiagramMember::Node(node) => { + Some((node.identifier.value.as_str(), node.identifier.span)) + } + DiagramMember::Group(group) => { + Some((group.identifier.value.as_str(), group.identifier.span)) + } + DiagramMember::Edge(_) | DiagramMember::Theme(_) | DiagramMember::Layout(_) => None, + }) + .collect() +} + +fn direct_group_children(group: &ast::Group) -> HashMap<&str, Span> { + group + .members + .iter() + .filter_map(|member| match member { + GroupMember::Node(node) => Some((node.identifier.value.as_str(), node.identifier.span)), + GroupMember::Group(group) => { + Some((group.identifier.value.as_str(), group.identifier.span)) + } + GroupMember::Layout(_) => None, + }) + .collect() +} + +fn visit_nodes<'ast>(diagram: &'ast ast::Diagram, visitor: &mut impl FnMut(&'ast ast::Node)) { + for member in &diagram.members { + match member { + DiagramMember::Node(node) => visitor(node), + DiagramMember::Group(group) => visit_group_nodes(group, visitor), + DiagramMember::Edge(_) | DiagramMember::Theme(_) | DiagramMember::Layout(_) => {} + } + } +} + +fn visit_group_nodes<'ast>(group: &'ast ast::Group, visitor: &mut impl FnMut(&'ast ast::Node)) { + for member in &group.members { + match member { + GroupMember::Node(node) => visitor(node), + GroupMember::Group(group) => visit_group_nodes(group, visitor), + GroupMember::Layout(_) => {} + } + } +} + +fn normalize(document: &ast::Document) -> ir::Diagram { + let mut nodes = Vec::new(); + let mut groups = Vec::new(); + let children = document + .diagram + .members + .iter() + .filter_map(|member| match member { + DiagramMember::Node(node) => Some(ir::ElementId::Node(node.identifier.value.clone())), + DiagramMember::Group(group) => { + Some(ir::ElementId::Group(group.identifier.value.clone())) + } + DiagramMember::Edge(_) | DiagramMember::Theme(_) | DiagramMember::Layout(_) => None, + }) + .collect(); + + for member in &document.diagram.members { + match member { + DiagramMember::Node(node) => nodes.push(normalize_node(node, None)), + DiagramMember::Group(group) => normalize_group(group, None, &mut nodes, &mut groups), + DiagramMember::Edge(_) | DiagramMember::Theme(_) | DiagramMember::Layout(_) => {} + } + } + + let edges = document + .diagram + .members + .iter() + .filter_map(|member| match member { + DiagramMember::Edge(edge) => Some(normalize_edge(edge)), + _ => None, + }) + .collect(); + let selected_theme = document + .diagram + .members + .iter() + .find_map(|member| match member { + DiagramMember::Theme(theme) => Some(theme.identifier.value.clone()), + _ => None, + }); + let theme_id = match selected_theme { + Some(theme_id) => theme_id, + None => "default".to_owned(), + }; + let layout = document + .diagram + .members + .iter() + .find_map(|member| match member { + DiagramMember::Layout(layout) => Some(normalize_layout(layout)), + _ => None, + }); + + ir::Diagram { + language_version: ir::LanguageVersion { + major: document.version.major, + minor: document.version.minor, + }, + title: document.diagram.title.value.clone(), + theme_id, + children, + nodes, + groups, + edges, + layout, + } +} + +fn normalize_node(node: &ast::Node, parent_group_id: Option<&str>) -> ir::Node { + let mut kind = ir::NodeKind::Service; + let mut icon_id = None; + let mut detail = None; + for property in &node.properties { + match property { + NodeProperty::Kind(value) => { + if let Some(parsed_kind) = parse_node_kind(&value.value) { + kind = parsed_kind; + } + } + NodeProperty::Icon(value) => icon_id = Some(value.value.clone()), + NodeProperty::Detail(value) => detail = Some(value.value.clone()), + } + } + ir::Node { + id: node.identifier.value.clone(), + label: node.label.value.clone(), + kind, + icon_id, + detail, + parent_group_id: parent_group_id.map(str::to_owned), + } +} + +fn normalize_group( + group: &ast::Group, + parent_group_id: Option<&str>, + nodes: &mut Vec, + groups: &mut Vec, +) { + let children = group + .members + .iter() + .filter_map(|member| match member { + GroupMember::Node(node) => Some(ir::ElementId::Node(node.identifier.value.clone())), + GroupMember::Group(group) => Some(ir::ElementId::Group(group.identifier.value.clone())), + GroupMember::Layout(_) => None, + }) + .collect(); + let layout = group.members.iter().find_map(|member| match member { + GroupMember::Layout(layout) => Some(normalize_layout(layout)), + _ => None, + }); + groups.push(ir::Group { + id: group.identifier.value.clone(), + label: group.label.value.clone(), + parent_group_id: parent_group_id.map(str::to_owned), + children, + layout, + }); + + for member in &group.members { + match member { + GroupMember::Node(node) => { + nodes.push(normalize_node(node, Some(&group.identifier.value))) + } + GroupMember::Group(child) => { + normalize_group(child, Some(&group.identifier.value), nodes, groups) + } + GroupMember::Layout(_) => {} + } + } +} + +fn normalize_edge(edge: &ast::Edge) -> ir::Edge { + let authored_kind = edge.properties.iter().find_map(|property| match property { + ast::EdgeProperty::Kind(value) => parse_edge_kind(&value.value), + }); + let kind = match authored_kind { + Some(kind) => kind, + None => ir::EdgeKind::Flow, + }; + let direction = match edge.operator.value { + ast::EdgeOperator::Forward => ir::EdgeDirection::Forward, + ast::EdgeOperator::Bidirectional => ir::EdgeDirection::Bidirectional, + ast::EdgeOperator::Association => ir::EdgeDirection::Association, + }; + ir::Edge { + from: edge.from.value.clone(), + to: edge.to.value.clone(), + direction, + kind, + label: edge.label.as_ref().map(|label| label.value.clone()), + } +} + +fn normalize_layout(layout: &ast::Layout) -> ir::Layout { + let mut direction = None; + let mut same_ranks = Vec::new(); + let mut order = None; + for statement in &layout.statements { + match statement { + LayoutStatement::Direction(value) => { + direction = match value.value.as_str() { + "right" => Some(ir::Direction::Right), + "down" => Some(ir::Direction::Down), + _ => None, + }; + } + LayoutStatement::RankSame(list) => same_ranks.push( + list.identifiers + .iter() + .map(|identifier| identifier.value.clone()) + .collect(), + ), + LayoutStatement::Order(list) => { + order = Some( + list.identifiers + .iter() + .map(|identifier| identifier.value.clone()) + .collect(), + ); + } + } + } + ir::Layout { + direction, + same_ranks, + order, + } +} + +#[cfg(test)] +mod tests; diff --git a/src/validation/tests.rs b/src/validation/tests.rs new file mode 100644 index 0000000..9964881 --- /dev/null +++ b/src/validation/tests.rs @@ -0,0 +1,310 @@ +use crate::diagnostic::Severity; +use crate::ir::{Direction, EdgeDirection, EdgeKind, ElementId, NodeKind}; + +fn compile(source: &str) -> crate::CompileOutput { + crate::compile(source) +} + +fn codes(output: &crate::CompileOutput) -> Vec<&'static str> { + output + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code) + .collect() +} + +#[test] +fn validation_applies_defaults_and_normalizes_structure() { + let output = compile( + r#"stack 1.0 +diagram "API" { + group platform "Platform" { +node app "Application" +node db "Database" { kind database } + } + edge app -> db +}"#, + ); + + assert!(output.diagnostics.is_empty()); + let Some(diagram) = output.diagram else { + return; + }; + assert_eq!(diagram.theme_id, "default"); + assert_eq!(diagram.children, vec![ElementId::Group("platform".into())]); + assert_eq!(diagram.nodes[0].kind, NodeKind::Service); + assert_eq!( + diagram.nodes[0].parent_group_id.as_deref(), + Some("platform") + ); + assert_eq!(diagram.nodes[1].kind, NodeKind::Database); + assert_eq!(diagram.edges[0].kind, EdgeKind::Flow); + assert_eq!(diagram.edges[0].direction, EdgeDirection::Forward); +} + +#[test] +fn validation_collects_independent_semantic_errors() { + let output = compile( + r#"stack 2.0 +diagram " Bad " { + group empty "Empty" {} + node Bad "" + node Bad "Duplicate" + edge missing -> empty +}"#, + ); + + let codes = codes(&output); + assert!(output.diagram.is_none()); + for expected in [ + "STK2001", "STK3001", "STK3002", "STK3003", "STK3004", "STK3008", "STK3009", + ] { + assert!(codes.contains(&expected), "missing {expected}: {codes:?}"); + } +} + +#[test] +fn validation_rejects_duplicate_properties_edges_and_layout_singletons() { + let output = compile( + r#"stack 1.0 +diagram "Duplicates" { + layout { direction right direction down order [a, b] order [b, a] } + node a "A" { kind service kind worker } + node b "B" + edge a -- b { kind flow } + edge b -- a +}"#, + ); + + let codes = codes(&output); + assert!(codes.contains(&"STK3007")); + assert!(codes.contains(&"STK3012")); + assert!(codes.contains(&"STK3006")); +} + +#[test] +fn validation_checks_layout_scope_and_rank_membership() { + let output = compile( + r#"stack 1.0 +diagram "Layout" { + node outside "Outside" + group platform "Platform" { +layout { + rank same [a, b] + rank same [a, outside] + order [a, a] +} +node a "A" +node b "B" + } +}"#, + ); + + assert!( + codes(&output) + .iter() + .filter(|code| **code == "STK3011") + .count() + >= 3 + ); +} + +#[test] +fn validation_checks_icons_and_text() { + let output = compile( + r#"stack 1.0 +diagram "Icons" { + node app "App" { icon "Bad_icon" detail " detail" } +}"#, + ); + + let codes = codes(&output); + assert!(codes.contains(&"STK3013")); + assert!(codes.contains(&"STK3008")); +} + +#[test] +fn validation_warns_when_node_degree_exceeds_twelve() { + let mut source = String::from("stack 1.0\ndiagram \"Dense\" {\n node hub \"Hub\"\n"); + for index in 0..13 { + source.push_str(&format!(" node n{index} \"N {index}\"\n")); + source.push_str(&format!(" edge hub -> n{index}\n")); + } + source.push('}'); + + let output = compile(&source); + assert!(output.diagram.is_some()); + assert_eq!(codes(&output), vec!["STK4002"]); +} + +#[test] +fn normalization_covers_all_node_edge_and_containment_variants() { + let output = compile( + r#"stack 1.0 +diagram "Complete IR" { + theme light + group outer "Outer" { +group inner "Inner" { + node actor "Actor" { kind actor } + node client "Client" { kind client } + node function "Function" { kind function } + node worker "Worker" { kind worker } + node database "Database" { kind database } + node cache "Cache" { kind cache } + node queue "Queue" { kind queue } + node storage "Storage" { kind storage } + node external "External" { kind external } +} + } + node service "Service" { kind service icon "api" detail "Public API" } + layout { +direction down +rank same [outer, service] +order [outer, service] + } + edge actor <-> client "Request" { kind request } + edge function -- worker "Dependency" { kind dependency } + edge database -> cache "Data" { kind data } + edge queue -> storage "Event" { kind event } + edge external -> service "Flow" { kind flow } +}"#, + ); + + assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics); + let Some(diagram) = output.diagram else { + return; + }; + assert_eq!(diagram.theme_id, "light"); + assert_eq!(diagram.groups.len(), 2); + assert_eq!(diagram.nodes.len(), 10); + assert_eq!(diagram.edges.len(), 5); + assert_eq!(diagram.children[0].as_str(), "outer"); + assert_eq!(diagram.children[1].as_str(), "service"); + assert_eq!( + diagram + .nodes + .iter() + .map(|node| node.kind) + .collect::>(), + vec![ + NodeKind::Actor, + NodeKind::Client, + NodeKind::Function, + NodeKind::Worker, + NodeKind::Database, + NodeKind::Cache, + NodeKind::Queue, + NodeKind::Storage, + NodeKind::External, + NodeKind::Service, + ] + ); + assert_eq!(diagram.edges[0].direction, EdgeDirection::Bidirectional); + assert_eq!(diagram.edges[0].kind, EdgeKind::Request); + assert_eq!(diagram.edges[1].direction, EdgeDirection::Association); + assert_eq!(diagram.edges[1].kind, EdgeKind::Dependency); + assert_eq!(diagram.edges[2].kind, EdgeKind::Data); + assert_eq!(diagram.edges[3].kind, EdgeKind::Event); + assert_eq!(diagram.edges[4].kind, EdgeKind::Flow); + assert_eq!( + diagram.layout.as_ref().and_then(|layout| layout.direction), + Some(Direction::Down) + ); +} + +#[test] +fn validation_reports_less_common_semantic_failures() { + let output = compile( + r#"stack 1.0 +diagram "Invalid variants" { + theme first + theme second + node a "A" { kind unknown } + node b "B" + layout { direction sideways } + layout { direction right } + edge Bad -> a + edge a -> a + edge a -> b { kind unknown } +}"#, + ); + let codes = codes(&output); + + assert!(output.diagram.is_none()); + for expected in ["STK2002", "STK3001", "STK3005", "STK3012", "STK3014"] { + assert!(codes.contains(&expected), "missing {expected}: {codes:?}"); + } +} + +#[test] +fn validation_rejects_excessive_group_depth() { + let output = compile( + r#"stack 1.0 +diagram "Deep" { + group one "One" { +group two "Two" { + group three "Three" { + group four "Four" { + node leaf "Leaf" + } + } +} + } +}"#, + ); + + assert!(output.diagram.is_none()); + assert!(codes(&output).contains(&"STK3010")); +} + +#[test] +fn complexity_limits_cover_nodes_groups_and_edges() { + let mut too_many_nodes = String::from("stack 1.0\ndiagram \"Nodes\" {\n"); + for index in 0..41 { + too_many_nodes.push_str(&format!(" node n{index} \"Node {index}\"\n")); + } + too_many_nodes.push('}'); + assert!(codes(&compile(&too_many_nodes)).contains(&"STK4003")); + + let mut too_many_groups = String::from("stack 1.0\ndiagram \"Groups\" {\n"); + for index in 0..13 { + too_many_groups.push_str(&format!( + " group g{index} \"Group {index}\" {{ node n{index} \"Node {index}\" }}\n" + )); + } + too_many_groups.push('}'); + assert!(codes(&compile(&too_many_groups)).contains(&"STK4003")); + + let mut too_many_edges = + String::from("stack 1.0\ndiagram \"Edges\" {\n node a \"A\"\n node b \"B\"\n"); + for index in 0..5 { + too_many_edges.push_str(&format!(" edge a -> b \"Edge {index}\"\n")); + } + too_many_edges.push('}'); + assert!(codes(&compile(&too_many_edges)).contains(&"STK4003")); +} + +#[test] +fn diagnostics_sort_errors_and_warnings_deterministically() { + let mut source = String::from("stack 1.0\ndiagram \" Dense\" {\n node hub \"Hub\"\n"); + for index in 0..13 { + source.push_str(&format!(" node n{index} \"N {index}\"\n")); + source.push_str(&format!(" edge hub -> n{index}\n")); + } + source.push('}'); + let output = compile(&source); + + assert!(output.diagram.is_none()); + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error) + ); + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Warning) + ); +} diff --git a/tests/compiler.rs b/tests/compiler.rs new file mode 100644 index 0000000..05d6f45 --- /dev/null +++ b/tests/compiler.rs @@ -0,0 +1,109 @@ +use stack_compiler::{compile, compile_bytes, diagnostic::Severity, ir}; + +const VALID_EXAMPLES: &[(&str, &str)] = &[ + ("minimal", include_str!("fixtures/valid/01-minimal.stack")), + ( + "node semantics", + include_str!("fixtures/valid/02-node-semantics.stack"), + ), + ( + "groups and layout", + include_str!("fixtures/valid/03-groups-and-layout.stack"), + ), + ( + "commerce platform", + include_str!("fixtures/valid/04-commerce-platform.stack"), + ), +]; + +#[test] +fn canonical_examples_compile_without_diagnostics() { + for (name, source) in VALID_EXAMPLES { + let output = compile(source); + assert!( + output.diagnostics.is_empty(), + "{name}: {:?}", + output.diagnostics + ); + assert!(output.diagram.is_some(), "{name}"); + } +} + +#[test] +fn canonical_commerce_example_has_expected_normalized_shape() { + let output = compile(VALID_EXAMPLES[3].1); + assert!(output.diagram.is_some(), "{:?}", output.diagnostics); + let Some(diagram) = output.diagram else { + return; + }; + + assert_eq!(diagram.theme_id, "default"); + assert_eq!(diagram.nodes.len(), 13); + assert_eq!(diagram.groups.len(), 5); + assert_eq!(diagram.edges.len(), 12); + assert_eq!( + diagram.layout.as_ref().and_then(|layout| layout.direction), + Some(ir::Direction::Right), + ); +} + +#[test] +fn compiler_errors_prevent_ir() { + let output = compile( + r#"stack 1.0 +diagram "Invalid" { + group empty "Empty" {} + node api "API" + edge api -> missing +}"#, + ); + + assert!(output.diagram.is_none()); + assert!( + output + .diagnostics + .iter() + .all(|diagnostic| diagnostic.severity == Severity::Error) + ); + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "STK3003") + ); + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "STK3009") + ); +} + +#[test] +fn byte_api_reports_encoding_and_bom_errors() { + let invalid_utf8 = compile_bytes(b"stack 1.0\n\xff"); + assert_eq!(invalid_utf8.diagnostics[0].code, "STK1001"); + assert!(invalid_utf8.diagram.is_none()); + + let bom = compile_bytes("\u{feff}stack 1.0 diagram \"x\" { node x \"X\" }".as_bytes()); + assert_eq!(bom.diagnostics[0].code, "STK1002"); + assert!(bom.diagram.is_none()); +} + +#[test] +fn complexity_errors_and_degree_warnings_have_distinct_outcomes() { + let no_nodes = compile("stack 1.0 diagram \"Empty\" {}"); + assert!(no_nodes.diagram.is_none()); + assert_eq!(no_nodes.diagnostics[0].code, "STK4003"); + + let mut dense = String::from("stack 1.0\ndiagram \"Dense\" {\n node hub \"Hub\"\n"); + for index in 0..13 { + dense.push_str(&format!(" node n{index} \"N {index}\"\n")); + dense.push_str(&format!(" edge hub -> n{index}\n")); + } + dense.push('}'); + let warning = compile(&dense); + assert!(warning.diagram.is_some()); + assert_eq!(warning.diagnostics[0].code, "STK4002"); + assert_eq!(warning.diagnostics[0].severity, Severity::Warning); +} diff --git a/tests/fixtures/valid/01-minimal.stack b/tests/fixtures/valid/01-minimal.stack new file mode 100644 index 0000000..6a1b623 --- /dev/null +++ b/tests/fixtures/valid/01-minimal.stack @@ -0,0 +1,7 @@ +stack 1.0 + +diagram "Hello Stack" { + node web "Web app" + node api "API" + edge web -> api +} diff --git a/tests/fixtures/valid/02-node-semantics.stack b/tests/fixtures/valid/02-node-semantics.stack new file mode 100644 index 0000000..b28e806 --- /dev/null +++ b/tests/fixtures/valid/02-node-semantics.stack @@ -0,0 +1,21 @@ +stack 1.0 + +diagram "Application and datastore" { + theme light + + node app "Application" { + kind service + icon "service" + detail "Business logic" + } + + node db "Primary database" { + kind database + icon "postgresql" + detail "PostgreSQL" + } + + edge app -> db "SQL" { + kind data + } +} diff --git a/tests/fixtures/valid/03-groups-and-layout.stack b/tests/fixtures/valid/03-groups-and-layout.stack new file mode 100644 index 0000000..b2310aa --- /dev/null +++ b/tests/fixtures/valid/03-groups-and-layout.stack @@ -0,0 +1,52 @@ +stack 1.0 + +diagram "Public application" { + layout { + direction right + } + + group clients "Clients" { + layout { + direction down + rank same [browser, mobile] + order [browser, mobile] + } + + node browser "Browser" { + kind client + icon "browser" + } + + node mobile "Mobile app" { + kind client + icon "mobile" + } + } + + node gateway "Edge gateway" { + icon "gateway" + } + + group platform "Platform" { + node api "Application API" + node db "Primary database" { + kind database + } + } + + edge browser -> gateway "HTTPS" { + kind request + } + + edge mobile -> gateway "HTTPS" { + kind request + } + + edge gateway -> api "HTTPS" { + kind request + } + + edge api -> db "SQL" { + kind data + } +} diff --git a/tests/fixtures/valid/04-commerce-platform.stack b/tests/fixtures/valid/04-commerce-platform.stack new file mode 100644 index 0000000..b3a5029 --- /dev/null +++ b/tests/fixtures/valid/04-commerce-platform.stack @@ -0,0 +1,127 @@ +stack 1.0 + +diagram "Commerce platform" { + layout { + direction right + } + + node customer "Customer" { + kind actor + } + + group storefront "Storefront" { + node web "Web storefront" { + kind client + icon "nextjs" + detail "Next.js" + } + + node gateway "Edge gateway" { + icon "gateway" + } + } + + group commerce "Commerce services" { + layout { + direction down + rank same [catalog, checkout] + order [catalog, checkout] + } + + node catalog "Catalog API" { + detail "Products and pricing" + } + + node checkout "Checkout API" { + detail "Order orchestration" + } + } + + group asynchronous "Asynchronous processing" { + node events "Event bus" { + kind queue + } + + node fulfillment "Fulfillment worker" { + kind worker + } + + node notifications "Notification worker" { + kind worker + } + } + + group data "Data" { + node products "Product database" { + kind database + icon "postgresql" + } + + node orders "Order database" { + kind database + icon "postgresql" + } + + node assets "Product media" { + kind storage + } + } + + group partners "External systems" { + node payment "Payment provider" { + kind external + } + + node email "Email provider" { + kind external + } + } + + edge customer -> web "Browse and buy" { + kind request + } + + edge web -> gateway "HTTPS" { + kind request + } + + edge gateway -> catalog "Catalog requests" { + kind request + } + + edge gateway -> checkout "Checkout requests" { + kind request + } + + edge catalog -> products "SQL" { + kind data + } + + edge catalog -> assets "Media URLs" { + kind data + } + + edge checkout -> orders "Transactions" { + kind data + } + + edge checkout -> payment "Payment API" { + kind request + } + + edge checkout -> events "OrderPlaced" { + kind event + } + + edge events -> fulfillment "OrderPlaced" { + kind event + } + + edge events -> notifications "OrderPlaced" { + kind event + } + + edge notifications -> email "Send receipt" { + kind request + } +}