1#![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 resources;
28mod routing;
29mod scene;
30mod svg;
31
32mod language;
33mod provider;
34pub use language::{
35 CompletionItem, CompletionKind, CompletionOutput, Hover, HoverKind, HoverOutput,
36 LANGUAGE_INTELLIGENCE_SCHEMA_VERSION, TextEdit,
37};
38pub use provider::{ProviderAsset, ProviderPack};
39
40pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
42
43pub type OperationResult<T> = Result<T, OperationalError>;
45
46#[derive(Debug, Clone, Copy)]
48pub struct Engine<'catalog> {
49 catalog: &'catalog stack_theme::Catalog,
50 catalog_revision: &'catalog str,
51 provider_packs: &'catalog [ProviderPack],
52}
53
54#[derive(Debug)]
55struct PreparedScene<'catalog> {
56 scene: scene::Scene,
57 resources: resources::Resources<'catalog>,
58 diagnostics: Vec<Diagnostic>,
59}
60
61impl Engine<'static> {
62 #[must_use]
64 pub fn bundled() -> Self {
65 Self {
66 catalog: stack_theme::catalog(),
67 catalog_revision: stack_theme::CATALOG_REVISION,
68 provider_packs: &[],
69 }
70 }
71}
72
73impl Default for Engine<'static> {
74 fn default() -> Self {
75 Self::bundled()
76 }
77}
78
79impl<'catalog> Engine<'catalog> {
80 pub fn with_provider_packs(provider_packs: &'catalog [ProviderPack]) -> OperationResult<Self> {
85 Self::with_catalog_and_provider_packs(
86 stack_theme::catalog(),
87 stack_theme::CATALOG_REVISION,
88 provider_packs,
89 )
90 }
91
92 pub fn with_catalog(
99 catalog: &'catalog stack_theme::Catalog,
100 catalog_revision: &'catalog str,
101 ) -> OperationResult<Self> {
102 Self::with_catalog_and_provider_packs(catalog, catalog_revision, &[])
103 }
104
105 pub fn with_catalog_and_provider_packs(
110 catalog: &'catalog stack_theme::Catalog,
111 catalog_revision: &'catalog str,
112 provider_packs: &'catalog [ProviderPack],
113 ) -> OperationResult<Self> {
114 if !valid_catalog_revision(catalog_revision) {
115 return Err(OperationalError::InvalidCatalog {
116 reason: "catalog revision must be a lowercase sha256 digest",
117 });
118 }
119 if !catalog
120 .themes
121 .iter()
122 .any(|theme| theme.id == catalog.fallbacks.missing_theme_id)
123 {
124 return Err(OperationalError::InvalidCatalog {
125 reason: "missing-theme fallback does not reference an active theme",
126 });
127 }
128 if catalog.themes.iter().any(|theme| {
129 !theme
130 .icons
131 .iter()
132 .any(|icon| icon.id == catalog.fallbacks.missing_icon_id)
133 }) {
134 return Err(OperationalError::InvalidCatalog {
135 reason: "missing-icon fallback is not present in every theme",
136 });
137 }
138
139 if provider_packs.len() > 32 {
140 return Err(OperationalError::InvalidProviderPack {
141 reason: "an engine may contain at most 32 provider packs",
142 });
143 }
144 for (index, pack) in provider_packs.iter().enumerate() {
145 if provider_packs[..index]
146 .iter()
147 .any(|candidate| candidate.manifest().provider.id == pack.manifest().provider.id)
148 {
149 return Err(OperationalError::InvalidProviderPack {
150 reason: "provider namespaces must be unique",
151 });
152 }
153 }
154
155 Ok(Self {
156 catalog,
157 catalog_revision,
158 provider_packs,
159 })
160 }
161
162 pub fn format(&self, source: &[u8]) -> OperationResult<FormatOutput> {
164 let formatted = stack_formatter::format_bytes(source);
165 Ok(FormatOutput {
166 formatted_source: formatted.source,
167 diagnostics: portable_diagnostics(formatted.diagnostics),
168 metadata: self.metadata(declared_language_version(source)),
169 })
170 }
171
172 pub fn check(&self, source: &[u8]) -> OperationResult<CheckOutput> {
174 let compiled = stack_compiler::compile_bytes_with_source_map(source);
175 let mut diagnostics = portable_diagnostics(compiled.diagnostics);
176 if let Some(diagram) = &compiled.diagram {
177 let source_map = compiled.source_map.as_ref().ok_or(
178 OperationalError::InvalidIntermediateRepresentation {
179 reason: "compiler omitted the source map for normalized IR",
180 },
181 )?;
182 diagnostics.extend(self.prepare_scene(diagram, source_map)?.diagnostics);
183 }
184 Ok(CheckOutput {
185 diagnostics,
186 metadata: self.metadata(declared_language_version(source)),
187 })
188 }
189
190 pub fn render(&self, source: &[u8]) -> OperationResult<RenderOutput> {
196 let compiled = stack_compiler::compile_bytes_with_source_map(source);
197 let metadata = self.metadata(declared_language_version(source));
198 if compiled.diagram.is_none() {
199 return Ok(RenderOutput {
200 svg: None,
201 diagnostics: portable_diagnostics(compiled.diagnostics),
202 metadata,
203 provider_notices: Vec::new(),
204 });
205 }
206
207 let diagram = compiled.diagram.as_ref().ok_or(
208 OperationalError::InvalidIntermediateRepresentation {
209 reason: "compiler omitted normalized IR after successful compilation",
210 },
211 )?;
212 let source_map = compiled.source_map.as_ref().ok_or(
213 OperationalError::InvalidIntermediateRepresentation {
214 reason: "compiler omitted the source map for normalized IR",
215 },
216 )?;
217 let prepared = self.prepare_scene(diagram, source_map)?;
218 let mut diagnostics = portable_diagnostics(compiled.diagnostics);
219 diagnostics.extend(prepared.diagnostics);
220 let svg = svg::render(diagram, &prepared.scene, &prepared.resources, &metadata).map_err(
221 |error| OperationalError::InvalidIntermediateRepresentation {
222 reason: error.reason(),
223 },
224 )?;
225 Ok(RenderOutput {
226 svg: Some(svg),
227 diagnostics,
228 metadata,
229 provider_notices: prepared.resources.provider_notices(),
230 })
231 }
232
233 fn metadata(&self, language_version: Option<LanguageVersion>) -> EngineMetadata {
234 EngineMetadata {
235 engine_version: ENGINE_VERSION.to_owned(),
236 language_version,
237 theme_catalog_version: self.catalog.catalog_version.clone(),
238 theme_catalog_revision: self.catalog_revision.to_owned(),
239 }
240 }
241
242 fn prepare_scene(
243 &self,
244 diagram: &stack_compiler::ir::Diagram,
245 source_map: &stack_compiler::source_map::SourceMap,
246 ) -> OperationResult<PreparedScene<'catalog>> {
247 let resources = resources::Resources::resolve(diagram, self.catalog, self.provider_packs)
248 .map_err(|error| OperationalError::InvalidCatalog {
249 reason: error.reason(),
250 })?;
251 let scene = scene::layout(diagram, self.catalog).map_err(|error| {
252 OperationalError::InvalidIntermediateRepresentation {
253 reason: error.reason(),
254 }
255 })?;
256 if !scene.geometry_is_valid() {
257 return Err(OperationalError::InvalidIntermediateRepresentation {
258 reason: "layout produced invalid containment or overlap geometry",
259 });
260 }
261 let mut diagnostics = resources
262 .warnings
263 .iter()
264 .map(|warning| resource_diagnostic(warning, source_map))
265 .collect::<OperationResult<Vec<_>>>()?;
266 diagnostics.extend(
267 scene
268 .unsatisfied_orders
269 .iter()
270 .map(|scope| order_diagnostic(scope, source_map))
271 .collect::<OperationResult<Vec<_>>>()?,
272 );
273 Ok(PreparedScene {
274 scene,
275 resources,
276 diagnostics,
277 })
278 }
279}
280
281fn resource_diagnostic(
282 warning: &resources::ResourceWarning,
283 source_map: &stack_compiler::source_map::SourceMap,
284) -> OperationResult<Diagnostic> {
285 let (code, message, help, origin) = match warning {
286 resources::ResourceWarning::MissingTheme(identifier) => (
287 "STK6001",
288 format!("theme '{identifier}' is unavailable; default theme was used"),
289 "Install the requested theme or select an available theme.",
290 source_map.theme(),
291 ),
292 resources::ResourceWarning::MissingIcon { node_id, icon_id } => (
293 "STK5001",
294 format!("icon '{icon_id}' is unavailable; the missing-icon fallback was used"),
295 "Install the icon in the effective theme or remove the icon property.",
296 source_map.node_icon(node_id).ok_or(
297 OperationalError::InvalidIntermediateRepresentation {
298 reason: "source map omitted a normalized node",
299 },
300 )?,
301 ),
302 };
303 let span = origin
304 .span()
305 .ok_or(OperationalError::InvalidIntermediateRepresentation {
306 reason: "source map omitted an authored resource identifier",
307 })?;
308 Ok(Diagnostic {
309 code: code.to_owned(),
310 severity: Severity::Warning,
311 message,
312 range: SourceRange::from(span),
313 expected: Vec::new(),
314 help: Some(help.to_owned()),
315 related: Vec::new(),
316 })
317}
318
319fn order_diagnostic(
320 scope: &scene::SceneScope,
321 source_map: &stack_compiler::source_map::SourceMap,
322) -> OperationResult<Diagnostic> {
323 let origin = match scope {
324 scene::SceneScope::Diagram => source_map.diagram_order(),
325 scene::SceneScope::Group(identifier) => source_map.group_order(identifier).ok_or(
326 OperationalError::InvalidIntermediateRepresentation {
327 reason: "source map omitted a normalized group",
328 },
329 )?,
330 };
331 let span = origin
332 .span()
333 .ok_or(OperationalError::InvalidIntermediateRepresentation {
334 reason: "source map omitted an authored order hint",
335 })?;
336 Ok(Diagnostic {
337 code: "STK4001".to_owned(),
338 severity: Severity::Warning,
339 message: "order hint could not be satisfied by deterministic layout".to_owned(),
340 range: SourceRange::from(span),
341 expected: Vec::new(),
342 help: Some("Adjust the order hint or same-rank constraints.".to_owned()),
343 related: Vec::new(),
344 })
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
349#[non_exhaustive]
350pub enum OperationalError {
351 InvalidCatalog {
353 reason: &'static str,
355 },
356 InvalidProviderPack {
358 reason: &'static str,
360 },
361 InvalidLanguageIntelligenceInput {
363 reason: &'static str,
365 },
366 InvalidIntermediateRepresentation {
368 reason: &'static str,
370 },
371}
372
373impl fmt::Display for OperationalError {
374 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
375 match self {
376 Self::InvalidCatalog { reason } => write!(formatter, "invalid theme catalog: {reason}"),
377 Self::InvalidProviderPack { reason } => {
378 write!(formatter, "invalid provider pack: {reason}")
379 }
380 Self::InvalidLanguageIntelligenceInput { reason } => {
381 write!(formatter, "invalid language-intelligence input: {reason}")
382 }
383 Self::InvalidIntermediateRepresentation { reason } => {
384 write!(formatter, "invalid intermediate representation: {reason}")
385 }
386 }
387 }
388}
389
390impl Error for OperationalError {}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct EngineMetadata {
395 pub engine_version: String,
397 pub language_version: Option<LanguageVersion>,
399 pub theme_catalog_version: String,
401 pub theme_catalog_revision: String,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub struct LanguageVersion {
408 pub major: u32,
410 pub minor: u32,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct FormatOutput {
417 pub formatted_source: Option<String>,
419 pub diagnostics: Vec<Diagnostic>,
421 pub metadata: EngineMetadata,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct CheckOutput {
428 pub diagnostics: Vec<Diagnostic>,
430 pub metadata: EngineMetadata,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct RenderOutput {
437 pub svg: Option<String>,
439 pub diagnostics: Vec<Diagnostic>,
441 pub metadata: EngineMetadata,
443 pub provider_notices: Vec<ProviderNotice>,
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct ProviderNotice {
450 pub provider_id: String,
452 pub provider_name: String,
454 pub pack_version: String,
456 pub pack_revision: String,
458 pub source_release: String,
460 pub archive_sha256: String,
462 pub terms_url: String,
464 pub sources: Vec<ProviderNoticeSource>,
466 pub attribution: String,
468 pub terms_summary: String,
470 pub non_endorsement: String,
472 pub icons: Vec<ProviderNoticeIcon>,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct ProviderNoticeIcon {
479 pub id: String,
481 pub product_name: String,
483 pub brand_source_url: Option<String>,
485 pub brand_guidelines_url: Option<String>,
487 pub source_id: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct ProviderNoticeSource {
494 pub id: String,
496 pub page_url: String,
498 pub release: String,
500 pub archive_sha256: String,
502 pub terms_url: String,
504}
505
506#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct Diagnostic {
509 pub code: String,
511 pub severity: Severity,
513 pub message: String,
515 pub range: SourceRange,
517 pub expected: Vec<String>,
519 pub help: Option<String>,
521 pub related: Vec<RelatedInformation>,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum Severity {
528 Error,
530 Warning,
532}
533
534#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct RelatedInformation {
537 pub message: String,
539 pub range: SourceRange,
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub struct SourceRange {
546 pub start: SourcePosition,
548 pub end: SourcePosition,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub struct SourcePosition {
555 pub byte_offset: u64,
557 pub line: u64,
559 pub column: u64,
561}
562
563fn valid_catalog_revision(revision: &str) -> bool {
564 revision.strip_prefix("sha256:").is_some_and(|digest| {
565 digest.len() == 64
566 && digest
567 .bytes()
568 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
569 })
570}
571
572fn declared_language_version(source: &[u8]) -> Option<LanguageVersion> {
573 stack_compiler::parse_bytes(source)
574 .document
575 .map(|document| LanguageVersion {
576 major: document.version.major,
577 minor: document.version.minor,
578 })
579}
580
581fn portable_diagnostics(diagnostics: Vec<compiler_diagnostic::Diagnostic>) -> Vec<Diagnostic> {
582 diagnostics.into_iter().map(Diagnostic::from).collect()
583}
584
585impl From<compiler_diagnostic::Diagnostic> for Diagnostic {
586 fn from(diagnostic: compiler_diagnostic::Diagnostic) -> Self {
587 Self {
588 code: diagnostic.code.to_owned(),
589 severity: match diagnostic.severity {
590 compiler_diagnostic::Severity::Error => Severity::Error,
591 compiler_diagnostic::Severity::Warning => Severity::Warning,
592 },
593 message: diagnostic.message,
594 range: SourceRange::from(diagnostic.span),
595 expected: diagnostic.expected,
596 help: diagnostic.help,
597 related: diagnostic
598 .related
599 .into_iter()
600 .map(|related| RelatedInformation {
601 message: related.message,
602 range: SourceRange::from(related.span),
603 })
604 .collect(),
605 }
606 }
607}
608
609impl From<compiler_diagnostic::Span> for SourceRange {
610 fn from(span: compiler_diagnostic::Span) -> Self {
611 Self {
612 start: SourcePosition::from(span.start),
613 end: SourcePosition::from(span.end),
614 }
615 }
616}
617
618impl From<compiler_diagnostic::SourcePosition> for SourcePosition {
619 fn from(position: compiler_diagnostic::SourcePosition) -> Self {
620 Self {
621 byte_offset: position.byte_offset as u64,
622 line: position.line as u64,
623 column: position.column as u64,
624 }
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use std::error::Error;
631
632 use stack_compiler::diagnostic as compiler_diagnostic;
633
634 use super::{
635 Diagnostic, ENGINE_VERSION, Engine, LanguageVersion, OperationalError, Severity,
636 SourcePosition,
637 };
638
639 const VALID_SOURCE: &[u8] = b"stack 1.0 diagram \"API\" { node api \"API\" }";
640
641 #[test]
642 fn bundled_engine_reports_all_version_metadata() {
643 let engine = Engine::bundled();
644 let result = engine.check(VALID_SOURCE);
645 assert!(result.is_ok());
646 if let Ok(output) = result {
647 assert!(output.diagnostics.is_empty());
648 assert_eq!(output.metadata.engine_version, ENGINE_VERSION);
649 assert_eq!(
650 output.metadata.language_version,
651 Some(LanguageVersion { major: 1, minor: 0 })
652 );
653 assert_eq!(output.metadata.theme_catalog_version, "0.5.0");
654 assert_eq!(
655 output.metadata.theme_catalog_revision,
656 stack_theme::CATALOG_REVISION
657 );
658 assert_eq!(Engine::default().check(VALID_SOURCE), Ok(output));
659 }
660 }
661
662 #[test]
663 fn bundled_catalog_resolves_explicit_core_icons() -> Result<(), Box<dyn Error>> {
664 let expected_icons = [
665 ("api", "Application programming interface"),
666 ("web", "Web application"),
667 ("mobile", "Mobile application"),
668 ("desktop", "Desktop application"),
669 ("server", "Server host"),
670 ("container", "Application container"),
671 ("cluster", "Compute cluster"),
672 ("cloud", "Cloud environment"),
673 ("scheduler", "Scheduled execution"),
674 ("webhook", "Webhook endpoint"),
675 ("identity", "Identity and access"),
676 ("observability", "Observability system"),
677 ("gateway", "Network gateway"),
678 ("load-balancer", "Load balancer"),
679 ("dns", "Domain name service"),
680 ("cdn", "Content delivery network"),
681 ("firewall", "Network firewall"),
682 ("network", "Network topology"),
683 ("event", "Discrete event"),
684 ("stream", "Event stream"),
685 ("search", "Search service"),
686 ("analytics", "Analytics system"),
687 ("repository", "Source code repository"),
688 ("pipeline", "Delivery pipeline"),
689 ("secret", "Secret or credential"),
690 ("document", "Document or knowledge base"),
691 ("task", "Task or issue tracker"),
692 ("chat", "Chat or messaging tool"),
693 ("email", "Email delivery"),
694 ("ai", "Artificial intelligence system"),
695 ];
696 let catalog = stack_theme::catalog();
697 assert_eq!(catalog.catalog_version, "0.5.0");
698 assert_eq!(
699 stack_theme::CATALOG_REVISION,
700 "sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53"
701 );
702 for theme in &catalog.themes {
703 for (identifier, subject) in expected_icons {
704 let icon = theme
705 .icons
706 .iter()
707 .find(|icon| icon.id == identifier)
708 .ok_or("core icon is unavailable in a bundled theme")?;
709 assert_eq!(icon.subject, subject);
710 assert_eq!(icon.asset.path, format!("assets/core/{identifier}.svg"));
711 }
712 }
713
714 let source = b"stack 1.0 diagram \"Core icon\" { theme dark node gateway \"Gateway\" { kind service detail \"Public API\" icon \"gateway\" } }";
715 let checked = Engine::bundled().check(source)?;
716 let rendered = Engine::bundled().render(source)?;
717 assert!(checked.diagnostics.is_empty());
718 assert!(rendered.diagnostics.is_empty());
719 assert_eq!(rendered.metadata.theme_catalog_version, "0.5.0");
720 assert_eq!(
721 rendered.metadata.theme_catalog_revision,
722 stack_theme::CATALOG_REVISION
723 );
724 let svg = rendered.svg.ok_or("explicit icon render produced no SVG")?;
725 assert!(svg.contains("data-icon-id=\"gateway\""));
726 assert!(!svg.contains("data-icon-id=\"kind-external\""));
727 Ok(())
728 }
729
730 #[test]
731 fn format_preserves_semantic_diagnostics_but_not_syntax_failures() {
732 let engine = Engine::bundled();
733 let semantic_error = b"stack 1.0 diagram \"API\" { node api \"A\" node api \"B\" }";
734 let semantic_result = engine.format(semantic_error);
735 assert!(semantic_result.is_ok());
736 if let Ok(semantic) = semantic_result {
737 assert!(semantic.formatted_source.is_some());
738 assert!(!semantic.diagnostics.is_empty());
739 }
740
741 let encoding_result = engine.format(b"stack 1.0\n\xff");
742 assert!(encoding_result.is_ok());
743 if let Ok(encoding) = encoding_result {
744 assert!(encoding.formatted_source.is_none());
745 assert_eq!(encoding.diagnostics[0].code, "STK1001");
746 assert_eq!(encoding.metadata.language_version, None);
747 }
748 }
749
750 #[test]
751 fn check_keeps_compiler_diagnostic_order_and_positions() {
752 let source =
753 b"stack 1.0 diagram \"API\" { node api \"A\" node api \"B\" edge api -> missing }";
754 let expected = stack_compiler::compile_bytes(source)
755 .diagnostics
756 .into_iter()
757 .map(|diagnostic| diagnostic.code)
758 .collect::<Vec<_>>();
759 let result = Engine::bundled().check(source);
760 assert!(result.is_ok());
761 if let Ok(output) = result {
762 assert_eq!(
763 output
764 .diagnostics
765 .iter()
766 .map(|diagnostic| diagnostic.code.as_str())
767 .collect::<Vec<_>>(),
768 expected
769 );
770 assert!(
771 output
772 .diagnostics
773 .windows(2)
774 .all(|pair| pair[0].range.start.byte_offset <= pair[1].range.start.byte_offset)
775 );
776 }
777 }
778
779 #[test]
780 fn check_emits_order_warning_at_the_authored_statement() -> Result<(), Box<dyn Error>> {
781 let source = "stack 1.0 diagram \"Order\" { layout { direction right order [b, a] } node a \"A\" node b \"B\" }";
782 let output = Engine::bundled().check(source.as_bytes())?;
783 assert_eq!(output.diagnostics.len(), 1);
784 let diagnostic = &output.diagnostics[0];
785 assert_eq!(diagnostic.code, "STK4001");
786 assert_eq!(diagnostic.severity, Severity::Warning);
787 let start = source
788 .find("order [b, a]")
789 .ok_or("missing order statement")?;
790 let end = start + "order [b, a]".len();
791 assert_eq!(diagnostic.range.start.byte_offset, start as u64);
792 assert_eq!(diagnostic.range.end.byte_offset, end as u64);
793 assert_eq!(diagnostic.range.start.line, 1);
794 assert_eq!(diagnostic.range.start.column, start as u64 + 1);
795 assert_eq!(diagnostic.range.end.column, end as u64 + 1);
796 Ok(())
797 }
798
799 #[test]
800 fn check_omits_order_warning_when_rank_placement_satisfies_it() -> Result<(), Box<dyn Error>> {
801 let source = b"stack 1.0 diagram \"Order\" { layout { direction right rank same [a, b] order [b, a] } node a \"A\" node b \"B\" }";
802 let output = Engine::bundled().check(source)?;
803 assert!(output.diagnostics.is_empty());
804 Ok(())
805 }
806
807 #[test]
808 fn group_order_warning_uses_the_group_source_map_entry() -> Result<(), Box<dyn Error>> {
809 let source = "stack 1.0 diagram \"Group order\" { group pair \"Pair\" { layout { direction down order [b, a] } node a \"A\" node b \"B\" } }";
810 let output = Engine::bundled().check(source.as_bytes())?;
811 assert_eq!(output, Engine::bundled().check(source.as_bytes())?);
812 assert_eq!(
813 output
814 .diagnostics
815 .iter()
816 .map(|diagnostic| diagnostic.code.as_str())
817 .collect::<Vec<_>>(),
818 vec!["STK4001"]
819 );
820 let start = source
821 .find("order [b, a]")
822 .ok_or("missing order statement")?;
823 assert_eq!(output.diagnostics[0].range.start.byte_offset, start as u64);
824 Ok(())
825 }
826
827 #[test]
828 fn layout_warnings_follow_compiler_warnings() -> Result<(), Box<dyn Error>> {
829 let mut source = String::from(
830 "stack 1.0 diagram \"Warnings\" { layout { direction right order [n1, n0] } node hub \"Hub\" ",
831 );
832 for index in 0..13 {
833 source.push_str(&format!(
834 "node n{index} \"N {index}\" edge hub -> n{index} "
835 ));
836 }
837 source.push('}');
838 let output = Engine::bundled().check(source.as_bytes())?;
839 assert_eq!(
840 output
841 .diagnostics
842 .iter()
843 .map(|diagnostic| diagnostic.code.as_str())
844 .collect::<Vec<_>>(),
845 vec!["STK4002", "STK4001"]
846 );
847 Ok(())
848 }
849
850 #[test]
851 fn resource_fallbacks_report_authored_ranges_and_render_svg() -> Result<(), Box<dyn Error>> {
852 let source = "stack 1.0 diagram \"Fallbacks\" { theme neon layout { direction right order [b, a] } node a \"A\" { icon \"missing\" } node b \"B\" }";
853 let checked = Engine::bundled().check(source.as_bytes())?;
854 let rendered = Engine::bundled().render(source.as_bytes())?;
855 assert_eq!(checked.diagnostics, rendered.diagnostics);
856 assert_eq!(
857 rendered
858 .diagnostics
859 .iter()
860 .map(|diagnostic| diagnostic.code.as_str())
861 .collect::<Vec<_>>(),
862 vec!["STK6001", "STK5001", "STK4001"]
863 );
864
865 let theme_start = source.find("neon").ok_or("missing theme identifier")?;
866 assert_eq!(
867 rendered.diagnostics[0].range.start.byte_offset,
868 theme_start as u64
869 );
870 assert_eq!(
871 rendered.diagnostics[0].range.end.byte_offset,
872 (theme_start + "neon".len()) as u64
873 );
874 let icon_start = source.find("\"missing\"").ok_or("missing icon string")?;
875 assert_eq!(
876 rendered.diagnostics[1].range.start.byte_offset,
877 icon_start as u64
878 );
879 assert_eq!(
880 rendered.diagnostics[1].range.end.byte_offset,
881 (icon_start + "\"missing\"".len()) as u64
882 );
883 let svg = rendered.svg.ok_or("render produced no SVG")?;
884 assert!(svg.contains("data-theme-id=\"default\""));
885 assert!(svg.contains("data-icon-id=\"kind-external\""));
886 Ok(())
887 }
888
889 #[test]
890 fn render_is_repeatable_and_escapes_source_text() -> Result<(), Box<dyn Error>> {
891 let source = b"stack 1.0 diagram \"<script>&\" { node client \"\\\" onload=\\\"alert(1)<&>\" edge client -> api \"javascript:alert(1)\" node api \"API\" }";
892 let first = Engine::bundled().render(source)?;
893 let second = Engine::bundled().render(source)?;
894 assert_eq!(first, second);
895 let svg = first.svg.ok_or("render produced no SVG")?;
896 assert!(svg.contains("<script>&"));
897 assert!(svg.contains("" onload="alert(1)<&>"));
898 assert!(!svg.contains("<script"));
899 assert!(!svg.contains("href="));
900 Ok(())
901 }
902
903 #[cfg(feature = "conformance")]
904 #[test]
905 fn canonical_valid_fixtures_render_standalone_svg() -> Result<(), Box<dyn Error>> {
906 let specification = std::env::var("STACK_SPECIFICATION_DIR")?;
907 let valid_root = std::path::Path::new(&specification).join("conformance/valid");
908 let mut cases = std::fs::read_dir(&valid_root)?.collect::<Result<Vec<_>, _>>()?;
909 cases.sort_by_key(|entry| entry.file_name());
910 if cases.is_empty() {
911 return Err(format!("no valid fixtures found in {}", valid_root.display()).into());
912 }
913
914 for case in cases {
915 let source_path = case.path().join("source.stack");
916 if !source_path.is_file() {
917 continue;
918 }
919 let output = Engine::bundled().render(&std::fs::read(&source_path)?)?;
920 if output
921 .diagnostics
922 .iter()
923 .any(|diagnostic| diagnostic.severity == Severity::Error)
924 {
925 return Err(
926 format!("{} produced an error diagnostic", source_path.display()).into(),
927 );
928 }
929 let svg = output
930 .svg
931 .ok_or_else(|| format!("{} produced no standalone SVG", source_path.display()))?;
932 assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
933 assert!(svg.ends_with("</svg>\n"));
934 }
935 Ok(())
936 }
937
938 #[test]
939 fn render_separates_invalid_input_from_success() -> Result<(), Box<dyn Error>> {
940 let engine = Engine::bundled();
941 let result = engine.render(b"\xff");
942 assert!(result.is_ok());
943 if let Ok(output) = result {
944 assert!(output.svg.is_none());
945 assert_eq!(output.diagnostics[0].code, "STK1001");
946 assert_eq!(output.metadata.language_version, None);
947 }
948
949 let output = engine.render(VALID_SOURCE)?;
950 assert!(output.diagnostics.is_empty());
951 assert!(
952 output
953 .svg
954 .as_deref()
955 .is_some_and(|svg| svg.contains("<svg"))
956 );
957 Ok(())
958 }
959
960 #[test]
961 fn provided_catalog_requires_usable_fallbacks_and_revision() {
962 let catalog = stack_theme::catalog().clone();
963 assert!(Engine::with_catalog(&catalog, stack_theme::CATALOG_REVISION).is_ok());
964 assert!(matches!(
965 Engine::with_catalog(&catalog, "sha256:NOT-A-DIGEST"),
966 Err(OperationalError::InvalidCatalog { .. })
967 ));
968
969 let mut missing_theme = catalog.clone();
970 missing_theme.fallbacks.missing_theme_id = "missing".to_owned();
971 assert!(matches!(
972 Engine::with_catalog(&missing_theme, stack_theme::CATALOG_REVISION),
973 Err(OperationalError::InvalidCatalog { .. })
974 ));
975
976 let mut missing_icon = catalog;
977 missing_icon.fallbacks.missing_icon_id = "missing".to_owned();
978 assert!(matches!(
979 Engine::with_catalog(&missing_icon, stack_theme::CATALOG_REVISION),
980 Err(OperationalError::InvalidCatalog { .. })
981 ));
982 }
983
984 #[test]
985 fn diagnostic_conversion_keeps_expected_help_and_related_ranges() {
986 let start = compiler_diagnostic::SourcePosition {
987 byte_offset: 3,
988 line: 2,
989 column: 4,
990 };
991 let end = compiler_diagnostic::SourcePosition {
992 byte_offset: 7,
993 line: 2,
994 column: 8,
995 };
996 let diagnostic = compiler_diagnostic::Diagnostic {
997 code: "STK4002",
998 severity: compiler_diagnostic::Severity::Warning,
999 message: "warning".to_owned(),
1000 span: compiler_diagnostic::Span { start, end },
1001 expected: vec!["right".to_owned(), "down".to_owned()],
1002 help: Some("help".to_owned()),
1003 related: vec![compiler_diagnostic::RelatedInformation {
1004 message: "related".to_owned(),
1005 span: compiler_diagnostic::Span::point(start),
1006 }],
1007 };
1008
1009 let portable = Diagnostic::from(diagnostic);
1010 assert_eq!(portable.severity, Severity::Warning);
1011 assert_eq!(portable.expected, ["right", "down"]);
1012 assert_eq!(portable.help.as_deref(), Some("help"));
1013 assert_eq!(portable.related[0].message, "related");
1014 assert_eq!(
1015 portable.range.start,
1016 SourcePosition {
1017 byte_offset: 3,
1018 line: 2,
1019 column: 4,
1020 }
1021 );
1022 }
1023
1024 #[test]
1025 fn operational_error_messages_are_stable() {
1026 assert_eq!(
1027 OperationalError::InvalidCatalog { reason: "reason" }.to_string(),
1028 "invalid theme catalog: reason"
1029 );
1030 assert_eq!(
1031 OperationalError::InvalidIntermediateRepresentation { reason: "reason" }.to_string(),
1032 "invalid intermediate representation: reason"
1033 );
1034 assert_eq!(
1035 OperationalError::InvalidProviderPack { reason: "reason" }.to_string(),
1036 "invalid provider pack: reason"
1037 );
1038 assert_eq!(
1039 OperationalError::InvalidLanguageIntelligenceInput { reason: "reason" }.to_string(),
1040 "invalid language-intelligence input: reason"
1041 );
1042 }
1043}