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