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