Skip to main content

stack_engine/
lib.rs

1//! Pure operation facade and output contract for Stack diagrams.
2//!
3//! The facade accepts source bytes and a validated, versioned theme catalog. It
4//! never reads the filesystem, network, process environment, clock, locale, or
5//! host font APIs. Invalid user source is returned as ordered diagnostics in a
6//! successful operation result; [`OperationalError`] is reserved for failures
7//! in supplied execution inputs or violated internal pipeline invariants.
8//!
9//! ```
10//! use stack_engine::Engine;
11//!
12//! let engine = Engine::bundled();
13//! let output = engine.check(b"stack 1.0 diagram \"API\" { node api \"API\" }")?;
14//! assert!(output.diagnostics.is_empty());
15//! assert_eq!(output.metadata.language_version.map(|version| version.major), Some(1));
16//! # Ok::<(), stack_engine::OperationalError>(())
17//! ```
18
19#![forbid(unsafe_code)]
20#![deny(missing_docs)]
21
22use std::error::Error;
23use std::fmt;
24
25use stack_compiler::diagnostic as compiler_diagnostic;
26
27mod labels;
28mod resources;
29mod routing;
30mod scene;
31mod svg;
32
33#[cfg(test)]
34mod layout_quality;
35
36#[cfg(test)]
37mod layout_congestion;
38
39#[cfg(test)]
40mod placement_quality;
41
42mod language;
43mod provider;
44pub use language::{
45    CompletionItem, CompletionKind, CompletionOutput, Hover, HoverKind, HoverOutput,
46    LANGUAGE_INTELLIGENCE_SCHEMA_VERSION, TextEdit,
47};
48pub use provider::{ProviderAsset, ProviderPack};
49
50/// Version of the Rust engine facade.
51pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
52
53/// Result channel for failures outside user-authored Stack source.
54pub type OperationResult<T> = Result<T, OperationalError>;
55
56/// Pure engine facade backed by one validated catalog and revision.
57#[derive(Debug, Clone, Copy)]
58pub struct Engine<'catalog> {
59    catalog: &'catalog stack_theme::Catalog,
60    catalog_revision: &'catalog str,
61    provider_packs: &'catalog [ProviderPack],
62}
63
64#[derive(Debug)]
65struct PreparedScene<'catalog> {
66    scene: scene::Scene,
67    resources: resources::Resources<'catalog>,
68    diagnostics: Vec<Diagnostic>,
69}
70
71impl Engine<'static> {
72    /// Creates an engine backed by the catalog embedded in `stack-theme`.
73    #[must_use]
74    pub fn bundled() -> Self {
75        Self {
76            catalog: stack_theme::catalog(),
77            catalog_revision: stack_theme::CATALOG_REVISION,
78            provider_packs: &[],
79        }
80    }
81}
82
83impl Default for Engine<'static> {
84    fn default() -> Self {
85        Self::bundled()
86    }
87}
88
89impl<'catalog> Engine<'catalog> {
90    /// Creates an engine backed by the bundled catalog and validated provider packs.
91    ///
92    /// Provider assets remain caller-owned in-memory data. The engine performs
93    /// no discovery, file access, download, upload, caching, or terms decision.
94    pub fn with_provider_packs(provider_packs: &'catalog [ProviderPack]) -> OperationResult<Self> {
95        Self::with_catalog_and_provider_packs(
96            stack_theme::catalog(),
97            stack_theme::CATALOG_REVISION,
98            provider_packs,
99        )
100    }
101
102    /// Creates an engine from a previously validated catalog and its content revision.
103    ///
104    /// The catalog document must already have passed the public `stack-theme`
105    /// schema and asset validator. This constructor checks the fallback records
106    /// needed by every engine operation and rejects invalid execution input as an
107    /// operational error rather than a user-source diagnostic.
108    pub fn with_catalog(
109        catalog: &'catalog stack_theme::Catalog,
110        catalog_revision: &'catalog str,
111    ) -> OperationResult<Self> {
112        Self::with_catalog_and_provider_packs(catalog, catalog_revision, &[])
113    }
114
115    /// Creates an engine from a validated catalog and validated provider packs.
116    ///
117    /// Provider namespaces must be unique. Provider icons cannot replace core
118    /// catalog identifiers because [`ProviderPack::new`] requires namespaced IDs.
119    pub fn with_catalog_and_provider_packs(
120        catalog: &'catalog stack_theme::Catalog,
121        catalog_revision: &'catalog str,
122        provider_packs: &'catalog [ProviderPack],
123    ) -> OperationResult<Self> {
124        if !valid_catalog_revision(catalog_revision) {
125            return Err(OperationalError::InvalidCatalog {
126                reason: "catalog revision must be a lowercase sha256 digest",
127            });
128        }
129        if !catalog
130            .themes
131            .iter()
132            .any(|theme| theme.id == catalog.fallbacks.missing_theme_id)
133        {
134            return Err(OperationalError::InvalidCatalog {
135                reason: "missing-theme fallback does not reference an active theme",
136            });
137        }
138        if catalog.themes.iter().any(|theme| {
139            !theme
140                .icons
141                .iter()
142                .any(|icon| icon.id == catalog.fallbacks.missing_icon_id)
143        }) {
144            return Err(OperationalError::InvalidCatalog {
145                reason: "missing-icon fallback is not present in every theme",
146            });
147        }
148
149        if provider_packs.len() > 32 {
150            return Err(OperationalError::InvalidProviderPack {
151                reason: "an engine may contain at most 32 provider packs",
152            });
153        }
154        for (index, pack) in provider_packs.iter().enumerate() {
155            if provider_packs[..index]
156                .iter()
157                .any(|candidate| candidate.manifest().provider.id == pack.manifest().provider.id)
158            {
159                return Err(OperationalError::InvalidProviderPack {
160                    reason: "provider namespaces must be unique",
161                });
162            }
163        }
164
165        Ok(Self {
166            catalog,
167            catalog_revision,
168            provider_packs,
169        })
170    }
171
172    /// Formats source while preserving compiler diagnostics in authored order.
173    pub fn format(&self, source: &[u8]) -> OperationResult<FormatOutput> {
174        let formatted = stack_formatter::format_bytes(source);
175        Ok(FormatOutput {
176            formatted_source: formatted.source,
177            diagnostics: portable_diagnostics(formatted.diagnostics),
178            metadata: self.metadata(declared_language_version(source)),
179        })
180    }
181
182    /// Runs compiler, theme, layout, and routing validation without producing SVG.
183    pub fn check(&self, source: &[u8]) -> OperationResult<CheckOutput> {
184        let compiled = stack_compiler::compile_bytes_with_source_map(source);
185        let mut diagnostics = portable_diagnostics(compiled.diagnostics);
186        if let Some(diagram) = &compiled.diagram {
187            let source_map = compiled.source_map.as_ref().ok_or(
188                OperationalError::InvalidIntermediateRepresentation {
189                    reason: "compiler omitted the source map for normalized IR",
190                },
191            )?;
192            diagnostics.extend(self.prepare_scene(diagram, source_map)?.diagnostics);
193        }
194        Ok(CheckOutput {
195            diagnostics,
196            metadata: self.metadata(declared_language_version(source)),
197        })
198    }
199
200    /// Produces a deterministic standalone SVG from valid Stack source.
201    ///
202    /// Invalid source returns a normal [`RenderOutput`] with ordered diagnostics
203    /// and no SVG. Resource and layout warnings preserve a fallback SVG, while
204    /// invalid catalog or intermediate pipeline state uses [`OperationalError`].
205    pub fn render(&self, source: &[u8]) -> OperationResult<RenderOutput> {
206        let compiled = stack_compiler::compile_bytes_with_source_map(source);
207        let metadata = self.metadata(declared_language_version(source));
208        if compiled.diagram.is_none() {
209            return Ok(RenderOutput {
210                svg: None,
211                diagnostics: portable_diagnostics(compiled.diagnostics),
212                metadata,
213                provider_notices: Vec::new(),
214            });
215        }
216
217        let diagram = compiled.diagram.as_ref().ok_or(
218            OperationalError::InvalidIntermediateRepresentation {
219                reason: "compiler omitted normalized IR after successful compilation",
220            },
221        )?;
222        let source_map = compiled.source_map.as_ref().ok_or(
223            OperationalError::InvalidIntermediateRepresentation {
224                reason: "compiler omitted the source map for normalized IR",
225            },
226        )?;
227        let prepared = self.prepare_scene(diagram, source_map)?;
228        let mut diagnostics = portable_diagnostics(compiled.diagnostics);
229        diagnostics.extend(prepared.diagnostics);
230        let svg = svg::render(diagram, &prepared.scene, &prepared.resources, &metadata).map_err(
231            |error| OperationalError::InvalidIntermediateRepresentation {
232                reason: error.reason(),
233            },
234        )?;
235        Ok(RenderOutput {
236            svg: Some(svg),
237            diagnostics,
238            metadata,
239            provider_notices: prepared.resources.provider_notices(),
240        })
241    }
242
243    fn metadata(&self, language_version: Option<LanguageVersion>) -> EngineMetadata {
244        EngineMetadata {
245            engine_version: ENGINE_VERSION.to_owned(),
246            language_version,
247            theme_catalog_version: self.catalog.catalog_version.clone(),
248            theme_catalog_revision: self.catalog_revision.to_owned(),
249        }
250    }
251
252    fn prepare_scene(
253        &self,
254        diagram: &stack_compiler::ir::Diagram,
255        source_map: &stack_compiler::source_map::SourceMap,
256    ) -> OperationResult<PreparedScene<'catalog>> {
257        let resources = resources::Resources::resolve(diagram, self.catalog, self.provider_packs)
258            .map_err(|error| OperationalError::InvalidCatalog {
259            reason: error.reason(),
260        })?;
261        let scene = scene::layout(diagram, self.catalog).map_err(|error| {
262            OperationalError::InvalidIntermediateRepresentation {
263                reason: error.reason(),
264            }
265        })?;
266        if !scene.geometry_is_valid() {
267            return Err(OperationalError::InvalidIntermediateRepresentation {
268                reason: "layout produced invalid containment or overlap geometry",
269            });
270        }
271        let mut diagnostics = resources
272            .warnings
273            .iter()
274            .map(|warning| resource_diagnostic(warning, source_map))
275            .collect::<OperationResult<Vec<_>>>()?;
276        diagnostics.extend(
277            scene
278                .unsatisfied_orders
279                .iter()
280                .map(|scope| order_diagnostic(scope, source_map))
281                .collect::<OperationResult<Vec<_>>>()?,
282        );
283        Ok(PreparedScene {
284            scene,
285            resources,
286            diagnostics,
287        })
288    }
289}
290
291fn resource_diagnostic(
292    warning: &resources::ResourceWarning,
293    source_map: &stack_compiler::source_map::SourceMap,
294) -> OperationResult<Diagnostic> {
295    let (code, message, help, origin) = match warning {
296        resources::ResourceWarning::MissingTheme(identifier) => (
297            "STK6001",
298            format!("theme '{identifier}' is unavailable; default theme was used"),
299            "Install the requested theme or select an available theme.",
300            source_map.theme(),
301        ),
302        resources::ResourceWarning::MissingIcon { node_id, icon_id } => (
303            "STK5001",
304            format!("icon '{icon_id}' is unavailable; the missing-icon fallback was used"),
305            "Install the icon in the effective theme or remove the icon property.",
306            source_map.node_icon(node_id).ok_or(
307                OperationalError::InvalidIntermediateRepresentation {
308                    reason: "source map omitted a normalized node",
309                },
310            )?,
311        ),
312    };
313    let span = origin
314        .span()
315        .ok_or(OperationalError::InvalidIntermediateRepresentation {
316            reason: "source map omitted an authored resource identifier",
317        })?;
318    Ok(Diagnostic {
319        code: code.to_owned(),
320        severity: Severity::Warning,
321        message,
322        range: SourceRange::from(span),
323        expected: Vec::new(),
324        help: Some(help.to_owned()),
325        related: Vec::new(),
326    })
327}
328
329fn order_diagnostic(
330    scope: &scene::SceneScope,
331    source_map: &stack_compiler::source_map::SourceMap,
332) -> OperationResult<Diagnostic> {
333    let origin = match scope {
334        scene::SceneScope::Diagram => source_map.diagram_order(),
335        scene::SceneScope::Group(identifier) => source_map.group_order(identifier).ok_or(
336            OperationalError::InvalidIntermediateRepresentation {
337                reason: "source map omitted a normalized group",
338            },
339        )?,
340    };
341    let span = origin
342        .span()
343        .ok_or(OperationalError::InvalidIntermediateRepresentation {
344            reason: "source map omitted an authored order hint",
345        })?;
346    Ok(Diagnostic {
347        code: "STK4001".to_owned(),
348        severity: Severity::Warning,
349        message: "order hint could not be satisfied by deterministic layout".to_owned(),
350        range: SourceRange::from(span),
351        expected: Vec::new(),
352        help: Some("Adjust the order hint or same-rank constraints.".to_owned()),
353        related: Vec::new(),
354    })
355}
356
357/// Failure in execution inputs or internal pipeline invariants, not in Stack source.
358#[derive(Debug, Clone, PartialEq, Eq)]
359#[non_exhaustive]
360pub enum OperationalError {
361    /// A provided catalog violates an invariant required by pure execution.
362    InvalidCatalog {
363        /// Stable explanation of the violated catalog invariant.
364        reason: &'static str,
365    },
366    /// A provider pack violates safe deterministic rendering invariants.
367    InvalidProviderPack {
368        /// Stable explanation of the violated provider-pack invariant.
369        reason: &'static str,
370    },
371    /// A language-intelligence request violates its stateless input contract.
372    InvalidLanguageIntelligenceInput {
373        /// Stable explanation of the invalid source position or completion catalog.
374        reason: &'static str,
375    },
376    /// Compiler or layout data violates an invariant required by pure execution.
377    InvalidIntermediateRepresentation {
378        /// Stable explanation of the violated invariant.
379        reason: &'static str,
380    },
381}
382
383impl fmt::Display for OperationalError {
384    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
385        match self {
386            Self::InvalidCatalog { reason } => write!(formatter, "invalid theme catalog: {reason}"),
387            Self::InvalidProviderPack { reason } => {
388                write!(formatter, "invalid provider pack: {reason}")
389            }
390            Self::InvalidLanguageIntelligenceInput { reason } => {
391                write!(formatter, "invalid language-intelligence input: {reason}")
392            }
393            Self::InvalidIntermediateRepresentation { reason } => {
394                write!(formatter, "invalid intermediate representation: {reason}")
395            }
396        }
397    }
398}
399
400impl Error for OperationalError {}
401
402/// Version metadata attached to every operation output.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct EngineMetadata {
405    /// Semantic version of `stack-engine`.
406    pub engine_version: String,
407    /// Authored language version, absent when decoding or syntax parsing fails.
408    pub language_version: Option<LanguageVersion>,
409    /// Semantic version of the selected theme catalog.
410    pub theme_catalog_version: String,
411    /// Content revision of the selected theme catalog and icon bytes.
412    pub theme_catalog_revision: String,
413}
414
415/// Authored Stack language version when decoding and syntax parsing succeed.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub struct LanguageVersion {
418    /// Authored major language version.
419    pub major: u32,
420    /// Authored minor language version.
421    pub minor: u32,
422}
423
424/// Result of the format operation.
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct FormatOutput {
427    /// Canonical source, absent after encoding, lexical, or syntax failure.
428    pub formatted_source: Option<String>,
429    /// Compiler diagnostics in deterministic authored order.
430    pub diagnostics: Vec<Diagnostic>,
431    /// Versions that identify the exact operation implementation and inputs.
432    pub metadata: EngineMetadata,
433}
434
435/// Result of the check operation.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct CheckOutput {
438    /// Compiler, theme, and layout diagnostics in deterministic order.
439    pub diagnostics: Vec<Diagnostic>,
440    /// Versions that identify the exact operation implementation and inputs.
441    pub metadata: EngineMetadata,
442}
443
444/// Result of the render operation.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct RenderOutput {
447    /// Standalone SVG, absent whenever an error diagnostic prevents rendering.
448    pub svg: Option<String>,
449    /// Compiler, theme, and layout diagnostics in deterministic order.
450    pub diagnostics: Vec<Diagnostic>,
451    /// Versions that identify the exact operation implementation and inputs.
452    pub metadata: EngineMetadata,
453    /// Provider-specific notices for the exact assets embedded in this output.
454    pub provider_notices: Vec<ProviderNotice>,
455}
456
457/// Notice and provenance for one provider pack used by a rendered artifact.
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct ProviderNotice {
460    /// Stable provider namespace.
461    pub provider_id: String,
462    /// Human-readable provider name.
463    pub provider_name: String,
464    /// Provider-pack semantic version.
465    pub pack_version: String,
466    /// Deterministic hash of the manifest and processed asset bytes.
467    pub pack_revision: String,
468    /// Audited upstream release identifier.
469    pub source_release: String,
470    /// Complete official source archive SHA-256.
471    pub archive_sha256: String,
472    /// Provider terms reviewed for this pack.
473    pub terms_url: String,
474    /// Every audited archive that contributed an icon to this pack.
475    pub sources: Vec<ProviderNoticeSource>,
476    /// User-visible attribution text.
477    pub attribution: String,
478    /// User-visible terms summary.
479    pub terms_summary: String,
480    /// User-visible non-endorsement statement.
481    pub non_endorsement: String,
482    /// Exact provider icons embedded in the output.
483    pub icons: Vec<ProviderNoticeIcon>,
484}
485
486/// One provider icon listed in a rendered-artifact notice.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct ProviderNoticeIcon {
489    /// Namespaced provider icon identifier.
490    pub id: String,
491    /// Official provider product name.
492    pub product_name: String,
493    /// Rights-owner source for this brand icon, when the archive is multi-brand.
494    pub brand_source_url: Option<String>,
495    /// Rights-owner usage guidelines for this brand icon, when available.
496    pub brand_guidelines_url: Option<String>,
497    /// Pack-local source ID, or `primary` for the primary source.
498    pub source_id: String,
499}
500
501/// One audited archive listed in a rendered-artifact notice.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct ProviderNoticeSource {
504    /// Pack-local source ID. The primary source always uses `primary`.
505    pub id: String,
506    /// Official source page.
507    pub page_url: String,
508    /// Audited upstream release identifier.
509    pub release: String,
510    /// Complete official source archive SHA-256.
511    pub archive_sha256: String,
512    /// Terms reviewed for this source.
513    pub terms_url: String,
514}
515
516/// Engine-owned portable diagnostic shared by native and future WASM outputs.
517#[derive(Debug, Clone, PartialEq, Eq)]
518pub struct Diagnostic {
519    /// Stable Stack diagnostic identifier.
520    pub code: String,
521    /// Whether the diagnostic prevents an artifact from being produced.
522    pub severity: Severity,
523    /// Concise human-readable diagnostic description.
524    pub message: String,
525    /// Primary end-exclusive source range.
526    pub range: SourceRange,
527    /// Ordered source values or constructs valid at the primary range.
528    pub expected: Vec<String>,
529    /// Optional corrective guidance.
530    pub help: Option<String>,
531    /// Other source locations involved in the diagnostic.
532    pub related: Vec<RelatedInformation>,
533}
534
535/// Severity of one portable diagnostic.
536#[derive(Debug, Clone, Copy, PartialEq, Eq)]
537pub enum Severity {
538    /// Prevents normalized or rendered output as defined by the operation.
539    Error,
540    /// Preserves successful output while reporting actionable information.
541    Warning,
542}
543
544/// Additional source context related to a diagnostic.
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub struct RelatedInformation {
547    /// Description of the related source location.
548    pub message: String,
549    /// End-exclusive related source range.
550    pub range: SourceRange,
551}
552
553/// End-exclusive source range.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub struct SourceRange {
556    /// Inclusive source position.
557    pub start: SourcePosition,
558    /// Exclusive source position.
559    pub end: SourcePosition,
560}
561
562/// One-based line and column with a zero-based UTF-8 byte offset.
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub struct SourcePosition {
565    /// Zero-based UTF-8 byte offset.
566    pub byte_offset: u64,
567    /// One-based source line.
568    pub line: u64,
569    /// One-based Unicode scalar column.
570    pub column: u64,
571}
572
573fn valid_catalog_revision(revision: &str) -> bool {
574    revision.strip_prefix("sha256:").is_some_and(|digest| {
575        digest.len() == 64
576            && digest
577                .bytes()
578                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
579    })
580}
581
582fn declared_language_version(source: &[u8]) -> Option<LanguageVersion> {
583    stack_compiler::parse_bytes(source)
584        .document
585        .map(|document| LanguageVersion {
586            major: document.version.major,
587            minor: document.version.minor,
588        })
589}
590
591fn portable_diagnostics(diagnostics: Vec<compiler_diagnostic::Diagnostic>) -> Vec<Diagnostic> {
592    diagnostics.into_iter().map(Diagnostic::from).collect()
593}
594
595impl From<compiler_diagnostic::Diagnostic> for Diagnostic {
596    fn from(diagnostic: compiler_diagnostic::Diagnostic) -> Self {
597        Self {
598            code: diagnostic.code.to_owned(),
599            severity: match diagnostic.severity {
600                compiler_diagnostic::Severity::Error => Severity::Error,
601                compiler_diagnostic::Severity::Warning => Severity::Warning,
602            },
603            message: diagnostic.message,
604            range: SourceRange::from(diagnostic.span),
605            expected: diagnostic.expected,
606            help: diagnostic.help,
607            related: diagnostic
608                .related
609                .into_iter()
610                .map(|related| RelatedInformation {
611                    message: related.message,
612                    range: SourceRange::from(related.span),
613                })
614                .collect(),
615        }
616    }
617}
618
619impl From<compiler_diagnostic::Span> for SourceRange {
620    fn from(span: compiler_diagnostic::Span) -> Self {
621        Self {
622            start: SourcePosition::from(span.start),
623            end: SourcePosition::from(span.end),
624        }
625    }
626}
627
628impl From<compiler_diagnostic::SourcePosition> for SourcePosition {
629    fn from(position: compiler_diagnostic::SourcePosition) -> Self {
630        Self {
631            byte_offset: position.byte_offset as u64,
632            line: position.line as u64,
633            column: position.column as u64,
634        }
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use std::error::Error;
641
642    use stack_compiler::diagnostic as compiler_diagnostic;
643
644    use super::{
645        Diagnostic, ENGINE_VERSION, Engine, LanguageVersion, OperationalError, Severity,
646        SourcePosition,
647    };
648
649    const VALID_SOURCE: &[u8] = b"stack 1.0 diagram \"API\" { node api \"API\" }";
650
651    #[test]
652    fn valid_wide_connectors_render_labelled_edges_in_both_directions() -> Result<(), Box<dyn Error>>
653    {
654        for width in [1_500, 15_000, 15_998, 15_999, 16_000, 32_000] {
655            let mut catalog = stack_theme::catalog().clone();
656            for theme in &mut catalog.themes {
657                theme.connector.width_milli_px = width;
658            }
659            let engine = Engine::with_catalog(&catalog, stack_theme::CATALOG_REVISION)?;
660            for direction in ["right", "down"] {
661                let source = format!(
662                    "stack 1.0 diagram \"Wide stroke\" {{ layout {{ direction {direction} }} node a \"A\" node b \"B\" edge a -> b \"Request\" }}"
663                );
664                let output = engine.render(source.as_bytes())?;
665                assert!(
666                    output.diagnostics.is_empty(),
667                    "width={width}, direction={direction}"
668                );
669                assert!(
670                    output
671                        .svg
672                        .ok_or("missing wide-stroke SVG")?
673                        .contains("data-edge-label=\"Request\"")
674                );
675            }
676        }
677        Ok(())
678    }
679
680    #[test]
681    fn bundled_engine_reports_all_version_metadata() {
682        let engine = Engine::bundled();
683        let result = engine.check(VALID_SOURCE);
684        assert!(result.is_ok());
685        if let Ok(output) = result {
686            assert!(output.diagnostics.is_empty());
687            assert_eq!(output.metadata.engine_version, ENGINE_VERSION);
688            assert_eq!(
689                output.metadata.language_version,
690                Some(LanguageVersion { major: 1, minor: 0 })
691            );
692            assert_eq!(output.metadata.theme_catalog_version, "0.7.0");
693            assert_eq!(
694                output.metadata.theme_catalog_revision,
695                stack_theme::CATALOG_REVISION
696            );
697            assert_eq!(Engine::default().check(VALID_SOURCE), Ok(output));
698        }
699    }
700
701    #[test]
702    fn bundled_catalog_resolves_explicit_core_icons() -> Result<(), Box<dyn Error>> {
703        let expected_icons = [
704            ("api", "Application programming interface"),
705            ("web", "Web application"),
706            ("mobile", "Mobile application"),
707            ("desktop", "Desktop application"),
708            ("server", "Server host"),
709            ("container", "Application container"),
710            ("cluster", "Compute cluster"),
711            ("cloud", "Cloud environment"),
712            ("scheduler", "Scheduled execution"),
713            ("webhook", "Webhook endpoint"),
714            ("identity", "Identity and access"),
715            ("observability", "Observability system"),
716            ("gateway", "Network gateway"),
717            ("load-balancer", "Load balancer"),
718            ("dns", "Domain name service"),
719            ("cdn", "Content delivery network"),
720            ("firewall", "Network firewall"),
721            ("network", "Network topology"),
722            ("event", "Discrete event"),
723            ("stream", "Event stream"),
724            ("search", "Search service"),
725            ("analytics", "Analytics system"),
726            ("repository", "Source code repository"),
727            ("pipeline", "Delivery pipeline"),
728            ("secret", "Secret or credential"),
729            ("document", "Document or knowledge base"),
730            ("task", "Task or issue tracker"),
731            ("chat", "Chat or messaging tool"),
732            ("email", "Email delivery"),
733            ("ai", "Artificial intelligence system"),
734        ];
735        let catalog = stack_theme::catalog();
736        assert_eq!(catalog.catalog_version, "0.7.0");
737        assert_eq!(
738            stack_theme::CATALOG_REVISION,
739            "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a"
740        );
741        for theme in &catalog.themes {
742            for (identifier, subject) in expected_icons {
743                let icon = theme
744                    .icons
745                    .iter()
746                    .find(|icon| icon.id == identifier)
747                    .ok_or("core icon is unavailable in a bundled theme")?;
748                assert_eq!(icon.subject, subject);
749                assert_eq!(icon.asset.path, format!("assets/core/{identifier}.svg"));
750            }
751        }
752
753        let source = b"stack 1.0 diagram \"Core icon\" { theme dark node gateway \"Gateway\" { kind service detail \"Public API\" icon \"gateway\" } }";
754        let checked = Engine::bundled().check(source)?;
755        let rendered = Engine::bundled().render(source)?;
756        assert!(checked.diagnostics.is_empty());
757        assert!(rendered.diagnostics.is_empty());
758        assert_eq!(rendered.metadata.theme_catalog_version, "0.7.0");
759        assert_eq!(
760            rendered.metadata.theme_catalog_revision,
761            stack_theme::CATALOG_REVISION
762        );
763        let svg = rendered.svg.ok_or("explicit icon render produced no SVG")?;
764        assert!(svg.contains("data-icon-id=\"gateway\""));
765        assert!(!svg.contains("data-icon-id=\"kind-external\""));
766        Ok(())
767    }
768
769    #[test]
770    fn format_preserves_semantic_diagnostics_but_not_syntax_failures() {
771        let engine = Engine::bundled();
772        let semantic_error = b"stack 1.0 diagram \"API\" { node api \"A\" node api \"B\" }";
773        let semantic_result = engine.format(semantic_error);
774        assert!(semantic_result.is_ok());
775        if let Ok(semantic) = semantic_result {
776            assert!(semantic.formatted_source.is_some());
777            assert!(!semantic.diagnostics.is_empty());
778        }
779
780        let encoding_result = engine.format(b"stack 1.0\n\xff");
781        assert!(encoding_result.is_ok());
782        if let Ok(encoding) = encoding_result {
783            assert!(encoding.formatted_source.is_none());
784            assert_eq!(encoding.diagnostics[0].code, "STK1001");
785            assert_eq!(encoding.metadata.language_version, None);
786        }
787    }
788
789    #[test]
790    fn check_keeps_compiler_diagnostic_order_and_positions() {
791        let source =
792            b"stack 1.0 diagram \"API\" { node api \"A\" node api \"B\" edge api -> missing }";
793        let expected = stack_compiler::compile_bytes(source)
794            .diagnostics
795            .into_iter()
796            .map(|diagnostic| diagnostic.code)
797            .collect::<Vec<_>>();
798        let result = Engine::bundled().check(source);
799        assert!(result.is_ok());
800        if let Ok(output) = result {
801            assert_eq!(
802                output
803                    .diagnostics
804                    .iter()
805                    .map(|diagnostic| diagnostic.code.as_str())
806                    .collect::<Vec<_>>(),
807                expected
808            );
809            assert!(
810                output
811                    .diagnostics
812                    .windows(2)
813                    .all(|pair| pair[0].range.start.byte_offset <= pair[1].range.start.byte_offset)
814            );
815        }
816    }
817
818    #[test]
819    fn check_emits_order_warning_at_the_authored_statement() -> Result<(), Box<dyn Error>> {
820        let source = "stack 1.0 diagram \"Order\" { layout { direction right order [b, a] } node a \"A\" node b \"B\" }";
821        let output = Engine::bundled().check(source.as_bytes())?;
822        assert_eq!(output.diagnostics.len(), 1);
823        let diagnostic = &output.diagnostics[0];
824        assert_eq!(diagnostic.code, "STK4001");
825        assert_eq!(diagnostic.severity, Severity::Warning);
826        let start = source
827            .find("order [b, a]")
828            .ok_or("missing order statement")?;
829        let end = start + "order [b, a]".len();
830        assert_eq!(diagnostic.range.start.byte_offset, start as u64);
831        assert_eq!(diagnostic.range.end.byte_offset, end as u64);
832        assert_eq!(diagnostic.range.start.line, 1);
833        assert_eq!(diagnostic.range.start.column, start as u64 + 1);
834        assert_eq!(diagnostic.range.end.column, end as u64 + 1);
835        Ok(())
836    }
837
838    #[test]
839    fn check_omits_order_warning_when_rank_placement_satisfies_it() -> Result<(), Box<dyn Error>> {
840        let source = b"stack 1.0 diagram \"Order\" { layout { direction right rank same [a, b] order [b, a] } node a \"A\" node b \"B\" }";
841        let output = Engine::bundled().check(source)?;
842        assert!(output.diagnostics.is_empty());
843        Ok(())
844    }
845
846    #[test]
847    fn group_order_warning_uses_the_group_source_map_entry() -> Result<(), Box<dyn Error>> {
848        let source = "stack 1.0 diagram \"Group order\" { group pair \"Pair\" { layout { direction down order [b, a] } node a \"A\" node b \"B\" } }";
849        let output = Engine::bundled().check(source.as_bytes())?;
850        assert_eq!(output, Engine::bundled().check(source.as_bytes())?);
851        assert_eq!(
852            output
853                .diagnostics
854                .iter()
855                .map(|diagnostic| diagnostic.code.as_str())
856                .collect::<Vec<_>>(),
857            vec!["STK4001"]
858        );
859        let start = source
860            .find("order [b, a]")
861            .ok_or("missing order statement")?;
862        assert_eq!(output.diagnostics[0].range.start.byte_offset, start as u64);
863        Ok(())
864    }
865
866    #[test]
867    fn layout_warnings_follow_compiler_warnings() -> Result<(), Box<dyn Error>> {
868        let mut source = String::from(
869            "stack 1.0 diagram \"Warnings\" { layout { direction right order [hub, n0] } node hub \"Hub\" ",
870        );
871        for index in 0..13 {
872            source.push_str(&format!(
873                "node n{index} \"N {index}\" edge hub -> n{index} "
874            ));
875        }
876        source.push('}');
877        let output = Engine::bundled().check(source.as_bytes())?;
878        assert_eq!(
879            output
880                .diagnostics
881                .iter()
882                .map(|diagnostic| diagnostic.code.as_str())
883                .collect::<Vec<_>>(),
884            vec!["STK4002", "STK4001"]
885        );
886        Ok(())
887    }
888
889    #[test]
890    fn resource_fallbacks_report_authored_ranges_and_render_svg() -> Result<(), Box<dyn Error>> {
891        let source = "stack 1.0 diagram \"Fallbacks\" { theme neon layout { direction right order [b, a] } node a \"A\" { icon \"missing\" } node b \"B\" }";
892        let checked = Engine::bundled().check(source.as_bytes())?;
893        let rendered = Engine::bundled().render(source.as_bytes())?;
894        assert_eq!(checked.diagnostics, rendered.diagnostics);
895        assert_eq!(
896            rendered
897                .diagnostics
898                .iter()
899                .map(|diagnostic| diagnostic.code.as_str())
900                .collect::<Vec<_>>(),
901            vec!["STK6001", "STK5001", "STK4001"]
902        );
903
904        let theme_start = source.find("neon").ok_or("missing theme identifier")?;
905        assert_eq!(
906            rendered.diagnostics[0].range.start.byte_offset,
907            theme_start as u64
908        );
909        assert_eq!(
910            rendered.diagnostics[0].range.end.byte_offset,
911            (theme_start + "neon".len()) as u64
912        );
913        let icon_start = source.find("\"missing\"").ok_or("missing icon string")?;
914        assert_eq!(
915            rendered.diagnostics[1].range.start.byte_offset,
916            icon_start as u64
917        );
918        assert_eq!(
919            rendered.diagnostics[1].range.end.byte_offset,
920            (icon_start + "\"missing\"".len()) as u64
921        );
922        let svg = rendered.svg.ok_or("render produced no SVG")?;
923        assert!(svg.contains("data-theme-id=\"default\""));
924        assert!(svg.contains("data-icon-id=\"kind-external\""));
925        Ok(())
926    }
927
928    #[test]
929    fn render_is_repeatable_and_escapes_source_text() -> Result<(), Box<dyn Error>> {
930        let source = b"stack 1.0 diagram \"<script>&\" { node client \"\\\" onload=\\\"alert(1)<&>\" edge client -> api \"javascript:alert(1)\" node api \"API\" }";
931        let first = Engine::bundled().render(source)?;
932        let second = Engine::bundled().render(source)?;
933        assert_eq!(first, second);
934        let svg = first.svg.ok_or("render produced no SVG")?;
935        assert!(svg.contains("&lt;script&gt;&amp;"));
936        assert!(svg.contains("&quot; onload=&quot;alert(1)&lt;&amp;&gt;"));
937        assert!(!svg.contains("<script"));
938        assert!(!svg.contains("href="));
939        Ok(())
940    }
941
942    #[cfg(feature = "conformance")]
943    #[test]
944    fn canonical_valid_fixtures_render_standalone_svg() -> Result<(), Box<dyn Error>> {
945        let specification = std::env::var("STACK_SPECIFICATION_DIR")?;
946        let valid_root = std::path::Path::new(&specification).join("conformance/valid");
947        let mut cases = std::fs::read_dir(&valid_root)?.collect::<Result<Vec<_>, _>>()?;
948        cases.sort_by_key(|entry| entry.file_name());
949        if cases.is_empty() {
950            return Err(format!("no valid fixtures found in {}", valid_root.display()).into());
951        }
952
953        for case in cases {
954            let source_path = case.path().join("source.stack");
955            if !source_path.is_file() {
956                continue;
957            }
958            let output = Engine::bundled().render(&std::fs::read(&source_path)?)?;
959            if output
960                .diagnostics
961                .iter()
962                .any(|diagnostic| diagnostic.severity == Severity::Error)
963            {
964                return Err(
965                    format!("{} produced an error diagnostic", source_path.display()).into(),
966                );
967            }
968            let svg = output
969                .svg
970                .ok_or_else(|| format!("{} produced no standalone SVG", source_path.display()))?;
971            assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
972            assert!(svg.ends_with("</svg>\n"));
973        }
974        Ok(())
975    }
976
977    #[test]
978    fn render_separates_invalid_input_from_success() -> Result<(), Box<dyn Error>> {
979        let engine = Engine::bundled();
980        let result = engine.render(b"\xff");
981        assert!(result.is_ok());
982        if let Ok(output) = result {
983            assert!(output.svg.is_none());
984            assert_eq!(output.diagnostics[0].code, "STK1001");
985            assert_eq!(output.metadata.language_version, None);
986        }
987
988        let output = engine.render(VALID_SOURCE)?;
989        assert!(output.diagnostics.is_empty());
990        assert!(
991            output
992                .svg
993                .as_deref()
994                .is_some_and(|svg| svg.contains("<svg"))
995        );
996        Ok(())
997    }
998
999    #[test]
1000    fn provided_catalog_requires_usable_fallbacks_and_revision() {
1001        let catalog = stack_theme::catalog().clone();
1002        assert!(Engine::with_catalog(&catalog, stack_theme::CATALOG_REVISION).is_ok());
1003        assert!(matches!(
1004            Engine::with_catalog(&catalog, "sha256:NOT-A-DIGEST"),
1005            Err(OperationalError::InvalidCatalog { .. })
1006        ));
1007
1008        let mut missing_theme = catalog.clone();
1009        missing_theme.fallbacks.missing_theme_id = "missing".to_owned();
1010        assert!(matches!(
1011            Engine::with_catalog(&missing_theme, stack_theme::CATALOG_REVISION),
1012            Err(OperationalError::InvalidCatalog { .. })
1013        ));
1014
1015        let mut missing_icon = catalog;
1016        missing_icon.fallbacks.missing_icon_id = "missing".to_owned();
1017        assert!(matches!(
1018            Engine::with_catalog(&missing_icon, stack_theme::CATALOG_REVISION),
1019            Err(OperationalError::InvalidCatalog { .. })
1020        ));
1021    }
1022
1023    #[test]
1024    fn diagnostic_conversion_keeps_expected_help_and_related_ranges() {
1025        let start = compiler_diagnostic::SourcePosition {
1026            byte_offset: 3,
1027            line: 2,
1028            column: 4,
1029        };
1030        let end = compiler_diagnostic::SourcePosition {
1031            byte_offset: 7,
1032            line: 2,
1033            column: 8,
1034        };
1035        let diagnostic = compiler_diagnostic::Diagnostic {
1036            code: "STK4002",
1037            severity: compiler_diagnostic::Severity::Warning,
1038            message: "warning".to_owned(),
1039            span: compiler_diagnostic::Span { start, end },
1040            expected: vec!["right".to_owned(), "down".to_owned()],
1041            help: Some("help".to_owned()),
1042            related: vec![compiler_diagnostic::RelatedInformation {
1043                message: "related".to_owned(),
1044                span: compiler_diagnostic::Span::point(start),
1045            }],
1046        };
1047
1048        let portable = Diagnostic::from(diagnostic);
1049        assert_eq!(portable.severity, Severity::Warning);
1050        assert_eq!(portable.expected, ["right", "down"]);
1051        assert_eq!(portable.help.as_deref(), Some("help"));
1052        assert_eq!(portable.related[0].message, "related");
1053        assert_eq!(
1054            portable.range.start,
1055            SourcePosition {
1056                byte_offset: 3,
1057                line: 2,
1058                column: 4,
1059            }
1060        );
1061    }
1062
1063    #[test]
1064    fn operational_error_messages_are_stable() {
1065        assert_eq!(
1066            OperationalError::InvalidCatalog { reason: "reason" }.to_string(),
1067            "invalid theme catalog: reason"
1068        );
1069        assert_eq!(
1070            OperationalError::InvalidIntermediateRepresentation { reason: "reason" }.to_string(),
1071            "invalid intermediate representation: reason"
1072        );
1073        assert_eq!(
1074            OperationalError::InvalidProviderPack { reason: "reason" }.to_string(),
1075            "invalid provider pack: reason"
1076        );
1077        assert_eq!(
1078            OperationalError::InvalidLanguageIntelligenceInput { reason: "reason" }.to_string(),
1079            "invalid language-intelligence input: reason"
1080        );
1081    }
1082}