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 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
47pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
49
50pub type OperationResult<T> = Result<T, OperationalError>;
52
53#[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 #[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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
356#[non_exhaustive]
357pub enum OperationalError {
358 InvalidCatalog {
360 reason: &'static str,
362 },
363 InvalidProviderPack {
365 reason: &'static str,
367 },
368 InvalidLanguageIntelligenceInput {
370 reason: &'static str,
372 },
373 InvalidIntermediateRepresentation {
375 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#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct EngineMetadata {
402 pub engine_version: String,
404 pub language_version: Option<LanguageVersion>,
406 pub theme_catalog_version: String,
408 pub theme_catalog_revision: String,
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub struct LanguageVersion {
415 pub major: u32,
417 pub minor: u32,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct FormatOutput {
424 pub formatted_source: Option<String>,
426 pub diagnostics: Vec<Diagnostic>,
428 pub metadata: EngineMetadata,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct CheckOutput {
435 pub diagnostics: Vec<Diagnostic>,
437 pub metadata: EngineMetadata,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct RenderOutput {
444 pub svg: Option<String>,
446 pub diagnostics: Vec<Diagnostic>,
448 pub metadata: EngineMetadata,
450 pub provider_notices: Vec<ProviderNotice>,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct ProviderNotice {
457 pub provider_id: String,
459 pub provider_name: String,
461 pub pack_version: String,
463 pub pack_revision: String,
465 pub source_release: String,
467 pub archive_sha256: String,
469 pub terms_url: String,
471 pub sources: Vec<ProviderNoticeSource>,
473 pub attribution: String,
475 pub terms_summary: String,
477 pub non_endorsement: String,
479 pub icons: Vec<ProviderNoticeIcon>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct ProviderNoticeIcon {
486 pub id: String,
488 pub product_name: String,
490 pub brand_source_url: Option<String>,
492 pub brand_guidelines_url: Option<String>,
494 pub source_id: String,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct ProviderNoticeSource {
501 pub id: String,
503 pub page_url: String,
505 pub release: String,
507 pub archive_sha256: String,
509 pub terms_url: String,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct Diagnostic {
516 pub code: String,
518 pub severity: Severity,
520 pub message: String,
522 pub range: SourceRange,
524 pub expected: Vec<String>,
526 pub help: Option<String>,
528 pub related: Vec<RelatedInformation>,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum Severity {
535 Error,
537 Warning,
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct RelatedInformation {
544 pub message: String,
546 pub range: SourceRange,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub struct SourceRange {
553 pub start: SourcePosition,
555 pub end: SourcePosition,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
561pub struct SourcePosition {
562 pub byte_offset: u64,
564 pub line: u64,
566 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("<script>&"));
933 assert!(svg.contains("" onload="alert(1)<&>"));
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}