diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 46c4413..2001f24 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,6 +50,9 @@ jobs: STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification run: cargo +stable test -p stack-engine --features conformance --locked + - name: Validate standalone SVG snapshots + run: python3 scripts/validate-svg.py + - name: Build pure engine for WebAssembly run: | rustup target add wasm32-unknown-unknown wasm32-wasip1 --toolchain stable diff --git a/README.md b/README.md index 3d45c21..4ca9ec5 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ `stack-sh/engine` is the pure Rust execution engine for Stack architecture diagrams. -The workspace now provides canonical Stack source formatting, the pure `stack-engine` operation facade, deterministic theme-aware scene layout, and orthogonal edge routing. SVG rendering and WebAssembly adapters remain planned work. +The workspace now provides canonical Stack source formatting, the pure `stack-engine` operation facade, deterministic theme-aware scene layout and orthogonal edge routing, and safe standalone SVG rendering. A WebAssembly adapter remains planned work. -## Planned workspace +## Workspace -- `stack-engine`: implemented operation/output boundary, theme resolution, deterministic scene layout, edge routing, and validation beyond the compiler stage, plus planned standalone SVG rendering; +- `stack-engine`: operation/output boundary, theme and icon fallback resolution, deterministic scene layout, edge routing, validation beyond the compiler stage, and standalone SVG rendering; - `stack-formatter`: comment-preserving canonical formatting for Stack source files (implemented); - a WebAssembly adapter exposing the same pure operations to browser consumers. @@ -26,6 +26,7 @@ The workspace uses Rust 2024 with Rust 1.85 as its minimum supported version. Ru cargo test --workspace STACK_SPECIFICATION_DIR=../specification cargo test -p stack-formatter --features conformance --test conformance STACK_SPECIFICATION_DIR=../specification cargo test -p stack-engine --features conformance +python3 scripts/validate-svg.py cargo build -p stack-engine --target wasm32-unknown-unknown CARGO_TARGET_WASM32_WASIP1_RUNNER=wasmtime cargo test -p stack-engine --lib --target wasm32-wasip1 cross_target_numeric_fixture cargo fmt --check @@ -35,7 +36,9 @@ cargo doc --workspace --no-deps `stack-formatter` is pure and accepts source bytes or UTF-8 text. Lexical and syntax errors return diagnostics without formatted output. Syntactically valid source remains formattable when semantic diagnostics exist. -`stack-engine` exposes byte-oriented `format`, `check`, and reserved `render` methods through an engine bound to the embedded or a caller-provided validated catalog. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs, invalid normalized containment, routing failure, and unavailable pipeline stages use a separate operational-error channel. Checks and compiler-valid render attempts resolve the requested theme, validate deterministic integer geometry, and route ordered edges outside node interiors. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. Rendering remains unavailable until SVG integration lands. CI executes one exact numeric geometry fixture in both the native suite and a WASI build. +`stack-engine` exposes byte-oriented `format`, `check`, and `render` methods through an engine bound to the embedded or a caller-provided validated catalog. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. + +The renderer emits fixed-dimension standalone SVG with embedded catalog icons, local marker references, escaped authored text, accessible title and description metadata, and no script, event handler, external URL, host font measurement, or runtime I/O. Canonical SVG snapshots are byte-stable and parsed by `scripts/validate-svg.py`; set `UPDATE_STACK_SNAPSHOTS=1` only when intentionally regenerating them. CI also executes one exact numeric geometry fixture in both the native suite and a WASI build. ## Architecture @@ -43,6 +46,7 @@ cargo doc --workspace --no-deps - [`docs/decisions/0002-use-a-pure-versioned-engine-facade.md`](./docs/decisions/0002-use-a-pure-versioned-engine-facade.md) - [`docs/decisions/0003-use-integer-ranked-scene-layout.md`](./docs/decisions/0003-use-integer-ranked-scene-layout.md) - [`docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md`](./docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md) +- [`docs/decisions/0005-serialize-safe-standalone-svg.md`](./docs/decisions/0005-serialize-safe-standalone-svg.md) - [`docs/dependency-audit.md`](./docs/dependency-audit.md) ## Licensing diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index 2ca0347..993d3b0 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -4,7 +4,7 @@ //! never reads the filesystem, network, process environment, clock, locale, or //! host font APIs. Invalid user source is returned as ordered diagnostics in a //! successful operation result; [`OperationalError`] is reserved for failures -//! in the supplied execution inputs or unavailable engine capabilities. +//! in supplied execution inputs or violated internal pipeline invariants. //! //! ``` //! use stack_engine::Engine; @@ -24,8 +24,10 @@ use std::fmt; use stack_compiler::diagnostic as compiler_diagnostic; +mod resources; mod routing; mod scene; +mod svg; /// Version of the Rust engine facade. pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -40,6 +42,13 @@ pub struct Engine<'catalog> { catalog_revision: &'catalog str, } +#[derive(Debug)] +struct PreparedScene<'catalog> { + scene: scene::Scene, + resources: resources::Resources<'catalog>, + diagnostics: Vec, +} + impl Engine<'static> { /// Creates an engine backed by the catalog embedded in `stack-theme`. #[must_use] @@ -119,7 +128,7 @@ impl<'catalog> Engine<'catalog> { reason: "compiler omitted the source map for normalized IR", }, )?; - diagnostics.extend(self.validate_scene(diagram, source_map)?); + diagnostics.extend(self.prepare_scene(diagram, source_map)?.diagnostics); } Ok(CheckOutput { diagnostics, @@ -127,13 +136,11 @@ impl<'catalog> Engine<'catalog> { }) } - /// Applies compiler error semantics to the reserved render operation. + /// Produces a deterministic standalone SVG from valid Stack source. /// /// Invalid source returns a normal [`RenderOutput`] with ordered diagnostics - /// and no SVG. A compiler-valid document reaches the not-yet-integrated - /// layout and renderer boundary and returns [`OperationalError::PipelineUnavailable`]. - /// The latter branch is replaced by deterministic SVG output when the render - /// pipeline lands. + /// and no SVG. Resource and layout warnings preserve a fallback SVG, while + /// invalid catalog or intermediate pipeline state uses [`OperationalError`]. pub fn render(&self, source: &[u8]) -> OperationResult { let compiled = stack_compiler::compile_bytes_with_source_map(source); let metadata = self.metadata(declared_language_version(source)); @@ -145,17 +152,28 @@ impl<'catalog> Engine<'catalog> { }); } - if let Some(diagram) = &compiled.diagram { - let source_map = compiled.source_map.as_ref().ok_or( - OperationalError::InvalidIntermediateRepresentation { - reason: "compiler omitted the source map for normalized IR", - }, - )?; - self.validate_scene(diagram, source_map)?; - } - - Err(OperationalError::PipelineUnavailable { - operation: Operation::Render, + let diagram = compiled.diagram.as_ref().ok_or( + OperationalError::InvalidIntermediateRepresentation { + reason: "compiler omitted normalized IR after successful compilation", + }, + )?; + let source_map = compiled.source_map.as_ref().ok_or( + OperationalError::InvalidIntermediateRepresentation { + reason: "compiler omitted the source map for normalized IR", + }, + )?; + let prepared = self.prepare_scene(diagram, source_map)?; + let mut diagnostics = portable_diagnostics(compiled.diagnostics); + diagnostics.extend(prepared.diagnostics); + let svg = svg::render(diagram, &prepared.scene, &prepared.resources, &metadata).map_err( + |error| OperationalError::InvalidIntermediateRepresentation { + reason: error.reason(), + }, + )?; + Ok(RenderOutput { + svg: Some(svg), + diagnostics, + metadata, }) } @@ -168,11 +186,16 @@ impl<'catalog> Engine<'catalog> { } } - fn validate_scene( + fn prepare_scene( &self, diagram: &stack_compiler::ir::Diagram, source_map: &stack_compiler::source_map::SourceMap, - ) -> OperationResult> { + ) -> OperationResult> { + let resources = resources::Resources::resolve(diagram, self.catalog).map_err(|error| { + OperationalError::InvalidCatalog { + reason: error.reason(), + } + })?; let scene = scene::layout(diagram, self.catalog).map_err(|error| { OperationalError::InvalidIntermediateRepresentation { reason: error.reason(), @@ -183,14 +206,63 @@ impl<'catalog> Engine<'catalog> { reason: "layout produced invalid containment or overlap geometry", }); } - scene - .unsatisfied_orders + let mut diagnostics = resources + .warnings .iter() - .map(|scope| order_diagnostic(scope, source_map)) - .collect() + .map(|warning| resource_diagnostic(warning, source_map)) + .collect::>>()?; + diagnostics.extend( + scene + .unsatisfied_orders + .iter() + .map(|scope| order_diagnostic(scope, source_map)) + .collect::>>()?, + ); + Ok(PreparedScene { + scene, + resources, + diagnostics, + }) } } +fn resource_diagnostic( + warning: &resources::ResourceWarning, + source_map: &stack_compiler::source_map::SourceMap, +) -> OperationResult { + let (code, message, help, origin) = match warning { + resources::ResourceWarning::MissingTheme(identifier) => ( + "STK6001", + format!("theme '{identifier}' is unavailable; default theme was used"), + "Install the requested theme or select an available theme.", + source_map.theme(), + ), + resources::ResourceWarning::MissingIcon { node_id, icon_id } => ( + "STK5001", + format!("icon '{icon_id}' is unavailable; the missing-icon fallback was used"), + "Install the icon in the effective theme or remove the icon property.", + source_map.node_icon(node_id).ok_or( + OperationalError::InvalidIntermediateRepresentation { + reason: "source map omitted a normalized node", + }, + )?, + ), + }; + let span = origin + .span() + .ok_or(OperationalError::InvalidIntermediateRepresentation { + reason: "source map omitted an authored resource identifier", + })?; + Ok(Diagnostic { + code: code.to_owned(), + severity: Severity::Warning, + message, + range: SourceRange::from(span), + help: Some(help.to_owned()), + related: Vec::new(), + }) +} + fn order_diagnostic( scope: &scene::SceneScope, source_map: &stack_compiler::source_map::SourceMap, @@ -218,28 +290,7 @@ fn order_diagnostic( }) } -/// Operation whose execution may produce an operational error. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Operation { - /// Canonical source formatting. - Format, - /// Full renderability validation without SVG serialization. - Check, - /// Full validation followed by standalone SVG serialization. - Render, -} - -impl fmt::Display for Operation { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::Format => "format", - Self::Check => "check", - Self::Render => "render", - }) - } -} - -/// Failure in execution inputs or engine capability, not in Stack source. +/// Failure in execution inputs or internal pipeline invariants, not in Stack source. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum OperationalError { @@ -253,11 +304,6 @@ pub enum OperationalError { /// Stable explanation of the violated invariant. reason: &'static str, }, - /// The requested pure stage has not landed in this engine revision. - PipelineUnavailable { - /// Operation whose downstream stages are unavailable. - operation: Operation, - }, } impl fmt::Display for OperationalError { @@ -267,9 +313,6 @@ impl fmt::Display for OperationalError { Self::InvalidIntermediateRepresentation { reason } => { write!(formatter, "invalid intermediate representation: {reason}") } - Self::PipelineUnavailable { operation } => { - write!(formatter, "{operation} pipeline is unavailable") - } } } } @@ -455,7 +498,7 @@ mod tests { use stack_compiler::diagnostic as compiler_diagnostic; use super::{ - Diagnostic, ENGINE_VERSION, Engine, LanguageVersion, Operation, OperationalError, Severity, + Diagnostic, ENGINE_VERSION, Engine, LanguageVersion, OperationalError, Severity, SourcePosition, }; @@ -603,7 +646,95 @@ mod tests { } #[test] - fn invalid_render_input_is_not_an_operational_error() { + fn resource_fallbacks_report_authored_ranges_and_render_svg() -> Result<(), Box> { + let source = "stack 1.0 diagram \"Fallbacks\" { theme neon layout { direction right order [b, a] } node a \"A\" { icon \"missing\" } node b \"B\" }"; + let checked = Engine::bundled().check(source.as_bytes())?; + let rendered = Engine::bundled().render(source.as_bytes())?; + assert_eq!(checked.diagnostics, rendered.diagnostics); + assert_eq!( + rendered + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect::>(), + vec!["STK6001", "STK5001", "STK4001"] + ); + + let theme_start = source.find("neon").ok_or("missing theme identifier")?; + assert_eq!( + rendered.diagnostics[0].range.start.byte_offset, + theme_start as u64 + ); + assert_eq!( + rendered.diagnostics[0].range.end.byte_offset, + (theme_start + "neon".len()) as u64 + ); + let icon_start = source.find("\"missing\"").ok_or("missing icon string")?; + assert_eq!( + rendered.diagnostics[1].range.start.byte_offset, + icon_start as u64 + ); + assert_eq!( + rendered.diagnostics[1].range.end.byte_offset, + (icon_start + "\"missing\"".len()) as u64 + ); + let svg = rendered.svg.ok_or("render produced no SVG")?; + assert!(svg.contains("data-theme-id=\"default\"")); + assert!(svg.contains("data-icon-id=\"kind-external\"")); + Ok(()) + } + + #[test] + fn render_is_repeatable_and_escapes_source_text() -> Result<(), Box> { + let source = b"stack 1.0 diagram \"