1use std::{collections::BTreeSet, error::Error, fmt};
4
5use crate::{
6 ast, compile, compile_bytes,
7 diagnostic::{Diagnostic, SourcePosition, Span},
8 lexer::{self, Token, TokenKind},
9 parse,
10};
11
12pub const SCHEMA_VERSION: &str = "1.0";
14
15pub const MAX_COMPLETION_ICONS: usize = 4_096;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CompletionCatalogEntry {
21 pub id: String,
23 pub label: String,
25 pub detail: Option<String>,
27 pub documentation: Option<String>,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct CompletionCatalog {
34 pub icons: Vec<CompletionCatalogEntry>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TextEdit {
41 pub range: Span,
43 pub new_text: String,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CompletionKind {
50 Keyword,
52 Property,
54 EnumValue,
56 Identifier,
58 Icon,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CompletionItem {
65 pub label: String,
67 pub kind: CompletionKind,
69 pub detail: Option<String>,
71 pub documentation: Option<String>,
73 pub filter_text: String,
75 pub sort_text: String,
77 pub edit: TextEdit,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct CompletionOutput {
84 pub schema_version: &'static str,
86 pub document_version: u64,
88 pub diagnostics: Vec<Diagnostic>,
90 pub is_incomplete: bool,
92 pub items: Vec<CompletionItem>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum HoverKind {
99 Diagram,
101 Group,
103 Node,
105 Edge,
107 Property,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Hover {
114 pub range: Span,
116 pub kind: HoverKind,
118 pub label: String,
120 pub detail: Option<String>,
122 pub documentation: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct HoverOutput {
129 pub schema_version: &'static str,
131 pub document_version: u64,
133 pub diagnostics: Vec<Diagnostic>,
135 pub hover: Option<Hover>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum DocumentSymbolKind {
142 Diagram,
144 Group,
146 Node,
148 Edge,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct DocumentSymbol {
155 pub name: String,
157 pub kind: DocumentSymbolKind,
159 pub detail: Option<String>,
161 pub range: Span,
163 pub selection_range: Span,
165 pub children: Vec<DocumentSymbol>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct DocumentSymbolsOutput {
172 pub schema_version: &'static str,
174 pub document_version: u64,
176 pub diagnostics: Vec<Diagnostic>,
178 pub symbols: Vec<DocumentSymbol>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct DiagnosticsOutput {
185 pub schema_version: &'static str,
187 pub document_version: u64,
189 pub diagnostics: Vec<Diagnostic>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum IntelligenceError {
196 InvalidPosition,
198 CompletionCatalogTooLarge,
200 InvalidCompletionCatalogEntry {
202 index: usize,
204 },
205 DuplicateCompletionCatalogId {
207 index: usize,
209 },
210}
211
212impl fmt::Display for IntelligenceError {
213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214 match self {
215 Self::InvalidPosition => formatter.write_str("source position is invalid"),
216 Self::CompletionCatalogTooLarge => {
217 formatter.write_str("completion catalog exceeds the item limit")
218 }
219 Self::InvalidCompletionCatalogEntry { index } => {
220 write!(formatter, "completion catalog entry {index} is invalid")
221 }
222 Self::DuplicateCompletionCatalogId { index } => {
223 write!(
224 formatter,
225 "completion catalog entry {index} repeats an icon id"
226 )
227 }
228 }
229 }
230}
231
232impl Error for IntelligenceError {}
233
234pub fn diagnostics(source: &str, document_version: u64) -> DiagnosticsOutput {
236 diagnostics_bytes(source.as_bytes(), document_version)
237}
238
239pub fn diagnostics_bytes(source: &[u8], document_version: u64) -> DiagnosticsOutput {
241 DiagnosticsOutput {
242 schema_version: SCHEMA_VERSION,
243 document_version,
244 diagnostics: compile_bytes(source).diagnostics,
245 }
246}
247
248pub fn completion(
250 source: &str,
251 document_version: u64,
252 position: SourcePosition,
253 catalog: &CompletionCatalog,
254) -> Result<CompletionOutput, IntelligenceError> {
255 validate_position(source, position)?;
256 validate_catalog(catalog)?;
257
258 let parsed = parse(source);
259 let is_incomplete = parsed.document.is_none();
260 let compiled = compile(source);
261 let Ok(tokens) = lexer::tokenize(source) else {
262 return Ok(CompletionOutput {
263 schema_version: SCHEMA_VERSION,
264 document_version,
265 diagnostics: compiled.diagnostics,
266 is_incomplete: true,
267 items: Vec::new(),
268 });
269 };
270
271 let cursor = position.byte_offset;
272 let active_index = active_token_index(&tokens, cursor);
273 let current_index = active_index.unwrap_or_else(|| token_index_at_or_after(&tokens, cursor));
274 let (replacement, prefix) = replacement_and_prefix(source, &tokens, active_index, position)?;
275 let scopes = scopes_before(&tokens, current_index);
276 let context = completion_context(&tokens, current_index, scopes.last().copied());
277 let nodes = parsed
278 .document
279 .as_ref()
280 .map(document_nodes)
281 .unwrap_or_default();
282 let candidates = candidates_for(context, catalog, &nodes);
283 let mut items: Vec<_> = candidates
284 .into_iter()
285 .filter(|candidate| candidate.filter_text.starts_with(&prefix))
286 .map(|candidate| candidate.into_item(replacement))
287 .collect();
288 items.sort_by(|left, right| {
289 left.sort_text
290 .cmp(&right.sort_text)
291 .then_with(|| left.label.cmp(&right.label))
292 });
293
294 Ok(CompletionOutput {
295 schema_version: SCHEMA_VERSION,
296 document_version,
297 diagnostics: compiled.diagnostics,
298 is_incomplete,
299 items,
300 })
301}
302
303pub fn hover(
305 source: &str,
306 document_version: u64,
307 position: SourcePosition,
308) -> Result<HoverOutput, IntelligenceError> {
309 validate_position(source, position)?;
310 let parsed = parse(source);
311 let compiled = compile(source);
312 let resolved = parsed
313 .document
314 .as_ref()
315 .and_then(|document| hover_for_document(document, position.byte_offset));
316 Ok(HoverOutput {
317 schema_version: SCHEMA_VERSION,
318 document_version,
319 diagnostics: compiled.diagnostics,
320 hover: resolved,
321 })
322}
323
324pub fn document_symbols(source: &str, document_version: u64) -> DocumentSymbolsOutput {
326 let parsed = parse(source);
327 let compiled = compile(source);
328 let symbols = parsed
329 .document
330 .as_ref()
331 .map(diagram_symbol)
332 .into_iter()
333 .collect();
334 DocumentSymbolsOutput {
335 schema_version: SCHEMA_VERSION,
336 document_version,
337 diagnostics: compiled.diagnostics,
338 symbols,
339 }
340}
341
342fn hover_for_document(document: &ast::Document, byte_offset: usize) -> Option<Hover> {
343 if contains(document.diagram.title.span, byte_offset) {
344 return Some(Hover {
345 range: document.diagram.title.span,
346 kind: HoverKind::Diagram,
347 label: document.diagram.title.value.clone(),
348 detail: Some(format!(
349 "Stack {}.{} diagram",
350 document.version.major, document.version.minor
351 )),
352 documentation: None,
353 });
354 }
355
356 hover_in_diagram_members(document, &document.diagram.members, byte_offset)
357}
358
359fn hover_in_diagram_members(
360 document: &ast::Document,
361 members: &[ast::DiagramMember],
362 byte_offset: usize,
363) -> Option<Hover> {
364 for member in members {
365 let result = match member {
366 ast::DiagramMember::Node(node) => hover_for_node(node, byte_offset),
367 ast::DiagramMember::Group(group) => hover_for_group(document, group, byte_offset),
368 ast::DiagramMember::Edge(edge) => hover_for_edge(document, edge, byte_offset),
369 ast::DiagramMember::Theme(theme) if contains(theme.identifier.span, byte_offset) => {
370 Some(property_hover(
371 theme.identifier.span,
372 &theme.identifier.value,
373 "theme",
374 ))
375 }
376 ast::DiagramMember::Layout(layout) => hover_for_layout(document, layout, byte_offset),
377 ast::DiagramMember::Theme(_) => None,
378 };
379 if result.is_some() {
380 return result;
381 }
382 }
383 None
384}
385
386fn hover_for_group(
387 document: &ast::Document,
388 group: &ast::Group,
389 byte_offset: usize,
390) -> Option<Hover> {
391 if contains(group.identifier.span, byte_offset) || contains(group.label.span, byte_offset) {
392 let range = if contains(group.identifier.span, byte_offset) {
393 group.identifier.span
394 } else {
395 group.label.span
396 };
397 return Some(Hover {
398 range,
399 kind: HoverKind::Group,
400 label: group.label.value.clone(),
401 detail: Some(format!("group {}", group.identifier.value)),
402 documentation: None,
403 });
404 }
405 for member in &group.members {
406 let result = match member {
407 ast::GroupMember::Node(node) => hover_for_node(node, byte_offset),
408 ast::GroupMember::Group(child) => hover_for_group(document, child, byte_offset),
409 ast::GroupMember::Layout(layout) => hover_for_layout(document, layout, byte_offset),
410 };
411 if result.is_some() {
412 return result;
413 }
414 }
415 None
416}
417
418fn hover_for_node(node: &ast::Node, byte_offset: usize) -> Option<Hover> {
419 if contains(node.identifier.span, byte_offset) || contains(node.label.span, byte_offset) {
420 let range = if contains(node.identifier.span, byte_offset) {
421 node.identifier.span
422 } else {
423 node.label.span
424 };
425 return Some(node_hover(node, range));
426 }
427 for property in &node.properties {
428 if contains(property.span(), byte_offset) {
429 return Some(match property {
430 ast::NodeProperty::Kind(value) => {
431 property_hover(value.span, &value.value, "node kind")
432 }
433 ast::NodeProperty::Icon(value) => property_hover(value.span, &value.value, "icon"),
434 ast::NodeProperty::Detail(value) => {
435 property_hover(value.span, &value.value, "node detail")
436 }
437 });
438 }
439 }
440 None
441}
442
443fn hover_for_edge(document: &ast::Document, edge: &ast::Edge, byte_offset: usize) -> Option<Hover> {
444 for reference in [&edge.from, &edge.to] {
445 if contains(reference.span, byte_offset) {
446 return find_node(&document.diagram.members, &reference.value)
447 .map(|node| node_hover(node, reference.span));
448 }
449 }
450 if contains(edge.operator.span, byte_offset)
451 || edge
452 .label
453 .as_ref()
454 .is_some_and(|label| contains(label.span, byte_offset))
455 {
456 let range = edge
457 .label
458 .as_ref()
459 .filter(|label| contains(label.span, byte_offset))
460 .map_or(edge.operator.span, |label| label.span);
461 return Some(Hover {
462 range,
463 kind: HoverKind::Edge,
464 label: edge
465 .label
466 .as_ref()
467 .map_or_else(|| edge_name(edge), |label| label.value.clone()),
468 detail: Some(edge_detail(edge)),
469 documentation: None,
470 });
471 }
472 for property in &edge.properties {
473 if contains(property.span(), byte_offset) {
474 return Some(match property {
475 ast::EdgeProperty::Kind(value) => {
476 property_hover(value.span, &value.value, "edge kind")
477 }
478 });
479 }
480 }
481 None
482}
483
484fn hover_for_layout(
485 document: &ast::Document,
486 layout: &ast::Layout,
487 byte_offset: usize,
488) -> Option<Hover> {
489 for statement in &layout.statements {
490 let result = match statement {
491 ast::LayoutStatement::Direction(value) if contains(value.span, byte_offset) => {
492 Some(property_hover(value.span, &value.value, "layout direction"))
493 }
494 ast::LayoutStatement::RankSame(list) | ast::LayoutStatement::Order(list) => list
495 .identifiers
496 .iter()
497 .find(|identifier| contains(identifier.span, byte_offset))
498 .and_then(|identifier| {
499 find_node(&document.diagram.members, &identifier.value)
500 .map(|node| node_hover(node, identifier.span))
501 .or_else(|| {
502 find_group(&document.diagram.members, &identifier.value)
503 .map(|group| group_hover(group, identifier.span))
504 })
505 }),
506 ast::LayoutStatement::Direction(_) => None,
507 };
508 if result.is_some() {
509 return result;
510 }
511 }
512 None
513}
514
515fn node_hover(node: &ast::Node, range: Span) -> Hover {
516 Hover {
517 range,
518 kind: HoverKind::Node,
519 label: node.label.value.clone(),
520 detail: Some(node_detail(node)),
521 documentation: node.properties.iter().find_map(|property| match property {
522 ast::NodeProperty::Detail(value) => Some(value.value.clone()),
523 ast::NodeProperty::Kind(_) | ast::NodeProperty::Icon(_) => None,
524 }),
525 }
526}
527
528fn group_hover(group: &ast::Group, range: Span) -> Hover {
529 Hover {
530 range,
531 kind: HoverKind::Group,
532 label: group.label.value.clone(),
533 detail: Some(format!("group {}", group.identifier.value)),
534 documentation: None,
535 }
536}
537
538fn property_hover(range: Span, label: &str, detail: &str) -> Hover {
539 Hover {
540 range,
541 kind: HoverKind::Property,
542 label: label.into(),
543 detail: Some(detail.into()),
544 documentation: None,
545 }
546}
547
548fn diagram_symbol(document: &ast::Document) -> DocumentSymbol {
549 DocumentSymbol {
550 name: document.diagram.title.value.clone(),
551 kind: DocumentSymbolKind::Diagram,
552 detail: Some(format!(
553 "Stack {}.{} diagram",
554 document.version.major, document.version.minor
555 )),
556 range: document.diagram.span,
557 selection_range: document.diagram.title.span,
558 children: document
559 .diagram
560 .members
561 .iter()
562 .filter_map(diagram_member_symbol)
563 .collect(),
564 }
565}
566
567fn diagram_member_symbol(member: &ast::DiagramMember) -> Option<DocumentSymbol> {
568 match member {
569 ast::DiagramMember::Node(node) => Some(node_symbol(node)),
570 ast::DiagramMember::Group(group) => Some(group_symbol(group)),
571 ast::DiagramMember::Edge(edge) => Some(edge_symbol(edge)),
572 ast::DiagramMember::Theme(_) | ast::DiagramMember::Layout(_) => None,
573 }
574}
575
576fn group_symbol(group: &ast::Group) -> DocumentSymbol {
577 DocumentSymbol {
578 name: group.label.value.clone(),
579 kind: DocumentSymbolKind::Group,
580 detail: Some(format!("group {}", group.identifier.value)),
581 range: group.span,
582 selection_range: group.identifier.span,
583 children: group
584 .members
585 .iter()
586 .filter_map(group_member_symbol)
587 .collect(),
588 }
589}
590
591fn group_member_symbol(member: &ast::GroupMember) -> Option<DocumentSymbol> {
592 match member {
593 ast::GroupMember::Node(node) => Some(node_symbol(node)),
594 ast::GroupMember::Group(group) => Some(group_symbol(group)),
595 ast::GroupMember::Layout(_) => None,
596 }
597}
598
599fn node_symbol(node: &ast::Node) -> DocumentSymbol {
600 DocumentSymbol {
601 name: node.label.value.clone(),
602 kind: DocumentSymbolKind::Node,
603 detail: Some(node_detail(node)),
604 range: node.span,
605 selection_range: node.identifier.span,
606 children: Vec::new(),
607 }
608}
609
610fn edge_symbol(edge: &ast::Edge) -> DocumentSymbol {
611 DocumentSymbol {
612 name: edge_name(edge),
613 kind: DocumentSymbolKind::Edge,
614 detail: Some(edge_detail(edge)),
615 range: edge.span,
616 selection_range: Span::covering(edge.from.span, edge.to.span),
617 children: Vec::new(),
618 }
619}
620
621fn node_detail(node: &ast::Node) -> String {
622 let kind = node.properties.iter().find_map(|property| match property {
623 ast::NodeProperty::Kind(value) => Some(value.value.as_str()),
624 ast::NodeProperty::Icon(_) | ast::NodeProperty::Detail(_) => None,
625 });
626 format!(
627 "node {} 路 {}",
628 node.identifier.value,
629 kind.unwrap_or("service")
630 )
631}
632
633fn edge_name(edge: &ast::Edge) -> String {
634 format!(
635 "{} {} {}",
636 edge.from.value,
637 edge_operator_source(edge.operator.value),
638 edge.to.value
639 )
640}
641
642fn edge_detail(edge: &ast::Edge) -> String {
643 let kind = edge.properties.first().map(|property| match property {
644 ast::EdgeProperty::Kind(value) => value.value.as_str(),
645 });
646 format!(
647 "{} edge 路 {}",
648 edge_operator_name(edge.operator.value),
649 kind.unwrap_or("flow")
650 )
651}
652
653fn edge_operator_source(operator: ast::EdgeOperator) -> &'static str {
654 match operator {
655 ast::EdgeOperator::Forward => "->",
656 ast::EdgeOperator::Bidirectional => "<->",
657 ast::EdgeOperator::Association => "--",
658 }
659}
660
661fn edge_operator_name(operator: ast::EdgeOperator) -> &'static str {
662 match operator {
663 ast::EdgeOperator::Forward => "forward",
664 ast::EdgeOperator::Bidirectional => "bidirectional",
665 ast::EdgeOperator::Association => "association",
666 }
667}
668
669fn find_node<'document>(
670 members: &'document [ast::DiagramMember],
671 identifier: &str,
672) -> Option<&'document ast::Node> {
673 for member in members {
674 match member {
675 ast::DiagramMember::Node(node) if node.identifier.value == identifier => {
676 return Some(node);
677 }
678 ast::DiagramMember::Group(group) => {
679 if let Some(node) = find_node_in_group(&group.members, identifier) {
680 return Some(node);
681 }
682 }
683 ast::DiagramMember::Node(_)
684 | ast::DiagramMember::Edge(_)
685 | ast::DiagramMember::Theme(_)
686 | ast::DiagramMember::Layout(_) => {}
687 }
688 }
689 None
690}
691
692fn find_node_in_group<'document>(
693 members: &'document [ast::GroupMember],
694 identifier: &str,
695) -> Option<&'document ast::Node> {
696 for member in members {
697 match member {
698 ast::GroupMember::Node(node) if node.identifier.value == identifier => {
699 return Some(node);
700 }
701 ast::GroupMember::Group(group) => {
702 if let Some(node) = find_node_in_group(&group.members, identifier) {
703 return Some(node);
704 }
705 }
706 ast::GroupMember::Node(_) | ast::GroupMember::Layout(_) => {}
707 }
708 }
709 None
710}
711
712fn find_group<'document>(
713 members: &'document [ast::DiagramMember],
714 identifier: &str,
715) -> Option<&'document ast::Group> {
716 for member in members {
717 if let ast::DiagramMember::Group(group) = member {
718 if group.identifier.value == identifier {
719 return Some(group);
720 }
721 if let Some(found) = find_group_in_group(&group.members, identifier) {
722 return Some(found);
723 }
724 }
725 }
726 None
727}
728
729fn find_group_in_group<'document>(
730 members: &'document [ast::GroupMember],
731 identifier: &str,
732) -> Option<&'document ast::Group> {
733 for member in members {
734 if let ast::GroupMember::Group(group) = member {
735 if group.identifier.value == identifier {
736 return Some(group);
737 }
738 if let Some(found) = find_group_in_group(&group.members, identifier) {
739 return Some(found);
740 }
741 }
742 }
743 None
744}
745
746fn contains(span: Span, byte_offset: usize) -> bool {
747 span.start.byte_offset <= byte_offset && byte_offset < span.end.byte_offset
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751enum Scope {
752 Diagram,
753 Group,
754 Node,
755 Edge,
756 Layout,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
760enum CompletionContext {
761 Root,
762 DiagramMember,
763 GroupMember,
764 NodeProperty,
765 EdgeProperty,
766 LayoutStatement,
767 NodeKind,
768 EdgeKind,
769 Direction,
770 RankRelation,
771 Icon,
772 EdgeEndpoint { excluded: Option<String> },
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
776struct Candidate {
777 label: String,
778 kind: CompletionKind,
779 detail: Option<String>,
780 documentation: Option<String>,
781 filter_text: String,
782 sort_text: String,
783 new_text: String,
784}
785
786impl Candidate {
787 fn literal(label: &str, kind: CompletionKind, detail: &str, order: usize) -> Self {
788 Self {
789 label: label.into(),
790 kind,
791 detail: Some(detail.into()),
792 documentation: None,
793 filter_text: label.into(),
794 sort_text: format!("{order:04}:{label}"),
795 new_text: label.into(),
796 }
797 }
798
799 fn into_item(self, range: Span) -> CompletionItem {
800 CompletionItem {
801 label: self.label,
802 kind: self.kind,
803 detail: self.detail,
804 documentation: self.documentation,
805 filter_text: self.filter_text,
806 sort_text: self.sort_text,
807 edit: TextEdit {
808 range,
809 new_text: self.new_text,
810 },
811 }
812 }
813}
814
815fn candidates_for(
816 context: CompletionContext,
817 catalog: &CompletionCatalog,
818 nodes: &[(String, String)],
819) -> Vec<Candidate> {
820 match context {
821 CompletionContext::Root => literals(
822 &["stack", "diagram"],
823 CompletionKind::Keyword,
824 "document keyword",
825 ),
826 CompletionContext::DiagramMember => literals(
827 &["node", "group", "edge", "theme", "layout"],
828 CompletionKind::Keyword,
829 "diagram member",
830 ),
831 CompletionContext::GroupMember => literals(
832 &["node", "group", "layout"],
833 CompletionKind::Keyword,
834 "group member",
835 ),
836 CompletionContext::NodeProperty => literals(
837 &["kind", "icon", "detail"],
838 CompletionKind::Property,
839 "node property",
840 ),
841 CompletionContext::EdgeProperty => {
842 literals(&["kind"], CompletionKind::Property, "edge property")
843 }
844 CompletionContext::LayoutStatement => literals(
845 &["direction", "rank", "order"],
846 CompletionKind::Property,
847 "layout statement",
848 ),
849 CompletionContext::NodeKind => literals(
850 &[
851 "actor", "client", "service", "function", "worker", "database", "cache", "queue",
852 "storage", "external",
853 ],
854 CompletionKind::EnumValue,
855 "node kind",
856 ),
857 CompletionContext::EdgeKind => literals(
858 &["flow", "request", "event", "data", "dependency"],
859 CompletionKind::EnumValue,
860 "edge kind",
861 ),
862 CompletionContext::Direction => literals(
863 &["right", "down"],
864 CompletionKind::EnumValue,
865 "layout direction",
866 ),
867 CompletionContext::RankRelation => {
868 literals(&["same"], CompletionKind::Keyword, "rank relation")
869 }
870 CompletionContext::Icon => {
871 let mut icons: Vec<_> = catalog.icons.iter().collect();
872 icons.sort_by(|left, right| left.id.cmp(&right.id));
873 icons
874 .into_iter()
875 .map(|entry| Candidate {
876 label: entry.label.clone(),
877 kind: CompletionKind::Icon,
878 detail: entry.detail.clone(),
879 documentation: entry.documentation.clone(),
880 filter_text: entry.id.clone(),
881 sort_text: entry.id.clone(),
882 new_text: entry.id.clone(),
883 })
884 .collect()
885 }
886 CompletionContext::EdgeEndpoint { excluded } => nodes
887 .iter()
888 .enumerate()
889 .filter(|(_, (identifier, _))| excluded.as_ref() != Some(identifier))
890 .map(|(index, (identifier, label))| Candidate {
891 label: identifier.clone(),
892 kind: CompletionKind::Identifier,
893 detail: Some(format!("node 路 {label}")),
894 documentation: None,
895 filter_text: identifier.clone(),
896 sort_text: format!("{:04}:{identifier}", index + 1),
897 new_text: identifier.clone(),
898 })
899 .collect(),
900 }
901}
902
903fn literals(values: &[&str], kind: CompletionKind, detail: &str) -> Vec<Candidate> {
904 values
905 .iter()
906 .enumerate()
907 .map(|(index, value)| Candidate::literal(value, kind, detail, index + 1))
908 .collect()
909}
910
911fn completion_context(
912 tokens: &[Token],
913 current_index: usize,
914 scope: Option<Scope>,
915) -> CompletionContext {
916 let previous_index = current_index.checked_sub(1);
917 let previous = previous_index.and_then(|index| tokens.get(index));
918 if bare_value(previous) == Some("kind") {
919 return match scope {
920 Some(Scope::Edge) => CompletionContext::EdgeKind,
921 _ => CompletionContext::NodeKind,
922 };
923 }
924 if bare_value(previous) == Some("direction") {
925 return CompletionContext::Direction;
926 }
927 if bare_value(previous) == Some("rank") {
928 return CompletionContext::RankRelation;
929 }
930 if bare_value(previous) == Some("icon") {
931 return CompletionContext::Icon;
932 }
933 if previous.is_some_and(|token| {
934 matches!(
935 token.kind,
936 TokenKind::ForwardArrow | TokenKind::BidirectionalArrow | TokenKind::Association
937 )
938 }) {
939 let excluded = previous_index
940 .and_then(|index| index.checked_sub(1))
941 .and_then(|index| tokens.get(index))
942 .and_then(|token| bare_value(Some(token)))
943 .map(str::to_owned);
944 return CompletionContext::EdgeEndpoint { excluded };
945 }
946 if bare_value(previous) == Some("edge") {
947 return CompletionContext::EdgeEndpoint { excluded: None };
948 }
949
950 match scope {
951 Some(Scope::Diagram) => CompletionContext::DiagramMember,
952 Some(Scope::Group) => CompletionContext::GroupMember,
953 Some(Scope::Node) => CompletionContext::NodeProperty,
954 Some(Scope::Edge) => CompletionContext::EdgeProperty,
955 Some(Scope::Layout) => CompletionContext::LayoutStatement,
956 None => CompletionContext::Root,
957 }
958}
959
960fn token_index_at_or_after(tokens: &[Token], cursor: usize) -> usize {
961 tokens
962 .iter()
963 .position(|token| token.span.start.byte_offset >= cursor)
964 .unwrap_or(tokens.len())
965}
966
967fn active_token_index(tokens: &[Token], cursor: usize) -> Option<usize> {
968 tokens.iter().enumerate().find_map(|(index, token)| {
969 (!matches!(token.kind, TokenKind::End)
970 && token.span.start.byte_offset <= cursor
971 && cursor <= token.span.end.byte_offset)
972 .then_some(index)
973 })
974}
975
976fn replacement_and_prefix(
977 source: &str,
978 tokens: &[Token],
979 active_index: Option<usize>,
980 position: SourcePosition,
981) -> Result<(Span, String), IntelligenceError> {
982 let Some(token) = active_index.and_then(|index| tokens.get(index)) else {
983 return Ok((Span::point(position), String::new()));
984 };
985 match token.kind {
986 TokenKind::Bare(_) => Ok((
987 token.span,
988 source[token.span.start.byte_offset..position.byte_offset].to_owned(),
989 )),
990 TokenKind::String(_) if token.span.end.byte_offset > token.span.start.byte_offset + 1 => {
991 let start_offset = token.span.start.byte_offset + 1;
992 let end_offset = token.span.end.byte_offset - 1;
993 if position.byte_offset < start_offset || position.byte_offset > end_offset {
994 return Ok((Span::point(position), String::new()));
995 }
996 Ok((
997 Span {
998 start: position_at_offset(source, start_offset)?,
999 end: position_at_offset(source, end_offset)?,
1000 },
1001 source[start_offset..position.byte_offset].to_owned(),
1002 ))
1003 }
1004 _ => Ok((Span::point(position), String::new())),
1005 }
1006}
1007
1008fn scopes_before(tokens: &[Token], current_index: usize) -> Vec<Scope> {
1009 let mut scopes = Vec::new();
1010 for (index, token) in tokens.iter().take(current_index).enumerate() {
1011 match token.kind {
1012 TokenKind::LeftBrace => {
1013 if let Some(scope) = scope_for_left_brace(tokens, index) {
1014 scopes.push(scope);
1015 }
1016 }
1017 TokenKind::RightBrace => {
1018 scopes.pop();
1019 }
1020 _ => {}
1021 }
1022 }
1023 scopes
1024}
1025
1026fn scope_for_left_brace(tokens: &[Token], index: usize) -> Option<Scope> {
1027 let previous = index.checked_sub(1).and_then(|item| tokens.get(item));
1028 if bare_value(previous) == Some("layout") {
1029 return Some(Scope::Layout);
1030 }
1031 if index >= 5
1032 && bare_value(tokens.get(index - 5)) == Some("edge")
1033 && is_edge_operator(tokens.get(index - 3))
1034 && matches!(tokens[index - 1].kind, TokenKind::String(_))
1035 {
1036 return Some(Scope::Edge);
1037 }
1038 if index >= 4
1039 && bare_value(tokens.get(index - 4)) == Some("edge")
1040 && is_edge_operator(tokens.get(index - 2))
1041 {
1042 return Some(Scope::Edge);
1043 }
1044 if index >= 3 && matches!(tokens[index - 1].kind, TokenKind::String(_)) {
1045 match bare_value(tokens.get(index - 3)) {
1046 Some("node") => return Some(Scope::Node),
1047 Some("group") => return Some(Scope::Group),
1048 _ => {}
1049 }
1050 }
1051 if index >= 2
1052 && bare_value(tokens.get(index - 2)) == Some("diagram")
1053 && matches!(tokens[index - 1].kind, TokenKind::String(_))
1054 {
1055 return Some(Scope::Diagram);
1056 }
1057 None
1058}
1059
1060fn is_edge_operator(token: Option<&Token>) -> bool {
1061 token.is_some_and(|item| {
1062 matches!(
1063 item.kind,
1064 TokenKind::ForwardArrow | TokenKind::BidirectionalArrow | TokenKind::Association
1065 )
1066 })
1067}
1068
1069fn bare_value(token: Option<&Token>) -> Option<&str> {
1070 match token.map(|item| &item.kind) {
1071 Some(TokenKind::Bare(value)) => Some(value),
1072 _ => None,
1073 }
1074}
1075
1076fn document_nodes(document: &ast::Document) -> Vec<(String, String)> {
1077 let mut nodes = Vec::new();
1078 collect_diagram_nodes(&document.diagram.members, &mut nodes);
1079 nodes
1080}
1081
1082fn collect_diagram_nodes(members: &[ast::DiagramMember], nodes: &mut Vec<(String, String)>) {
1083 for member in members {
1084 match member {
1085 ast::DiagramMember::Node(node) => {
1086 nodes.push((node.identifier.value.clone(), node.label.value.clone()));
1087 }
1088 ast::DiagramMember::Group(group) => collect_group_nodes(&group.members, nodes),
1089 ast::DiagramMember::Edge(_)
1090 | ast::DiagramMember::Theme(_)
1091 | ast::DiagramMember::Layout(_) => {}
1092 }
1093 }
1094}
1095
1096fn collect_group_nodes(members: &[ast::GroupMember], nodes: &mut Vec<(String, String)>) {
1097 for member in members {
1098 match member {
1099 ast::GroupMember::Node(node) => {
1100 nodes.push((node.identifier.value.clone(), node.label.value.clone()));
1101 }
1102 ast::GroupMember::Group(group) => collect_group_nodes(&group.members, nodes),
1103 ast::GroupMember::Layout(_) => {}
1104 }
1105 }
1106}
1107
1108fn position_at_offset(
1109 source: &str,
1110 byte_offset: usize,
1111) -> Result<SourcePosition, IntelligenceError> {
1112 if byte_offset > source.len()
1113 || !source.is_char_boundary(byte_offset)
1114 || (byte_offset > 0
1115 && source.as_bytes()[byte_offset - 1] == b'\r'
1116 && source.as_bytes().get(byte_offset) == Some(&b'\n'))
1117 {
1118 return Err(IntelligenceError::InvalidPosition);
1119 }
1120
1121 let mut line = 1;
1122 let mut column = 1;
1123 let mut characters = source[..byte_offset].chars().peekable();
1124 while let Some(character) = characters.next() {
1125 if character == '\r' && characters.peek() == Some(&'\n') {
1126 characters.next();
1127 line += 1;
1128 column = 1;
1129 } else if character == '\n' {
1130 line += 1;
1131 column = 1;
1132 } else {
1133 column += 1;
1134 }
1135 }
1136 Ok(SourcePosition {
1137 byte_offset,
1138 line,
1139 column,
1140 })
1141}
1142
1143fn validate_position(source: &str, position: SourcePosition) -> Result<(), IntelligenceError> {
1144 if position_at_offset(source, position.byte_offset)? != position {
1145 return Err(IntelligenceError::InvalidPosition);
1146 }
1147 Ok(())
1148}
1149
1150fn validate_catalog(catalog: &CompletionCatalog) -> Result<(), IntelligenceError> {
1151 if catalog.icons.len() > MAX_COMPLETION_ICONS {
1152 return Err(IntelligenceError::CompletionCatalogTooLarge);
1153 }
1154
1155 let mut identifiers = BTreeSet::new();
1156 for (index, entry) in catalog.icons.iter().enumerate() {
1157 let valid = valid_icon_id(&entry.id)
1158 && text_within(&entry.label, 120)
1159 && optional_text_within(entry.detail.as_deref(), 240)
1160 && optional_text_within(entry.documentation.as_deref(), 1_000);
1161 if !valid {
1162 return Err(IntelligenceError::InvalidCompletionCatalogEntry { index });
1163 }
1164 if !identifiers.insert(entry.id.as_str()) {
1165 return Err(IntelligenceError::DuplicateCompletionCatalogId { index });
1166 }
1167 }
1168 Ok(())
1169}
1170
1171fn valid_icon_id(identifier: &str) -> bool {
1172 let mut segments = identifier.split(':');
1173 let first = segments.next();
1174 let second = segments.next();
1175 if segments.next().is_some() {
1176 return false;
1177 }
1178 match (first, second) {
1179 (Some(segment), None) => valid_icon_segment(segment),
1180 (Some(namespace), Some(icon)) => valid_icon_segment(namespace) && valid_icon_segment(icon),
1181 _ => false,
1182 }
1183}
1184
1185fn valid_icon_segment(segment: &str) -> bool {
1186 let mut bytes = segment.bytes();
1187 matches!(bytes.next(), Some(b'a'..=b'z'))
1188 && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1189}
1190
1191fn text_within(value: &str, maximum: usize) -> bool {
1192 let length = value.chars().count();
1193 (1..=maximum).contains(&length)
1194}
1195
1196fn optional_text_within(value: Option<&str>, maximum: usize) -> bool {
1197 value.is_none_or(|text| text_within(text, maximum))
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::{
1203 CompletionCatalog, CompletionCatalogEntry, CompletionContext, CompletionKind,
1204 DocumentSymbolKind, HoverKind, IntelligenceError, MAX_COMPLETION_ICONS, Scope,
1205 candidates_for, completion, diagnostics, diagnostics_bytes, document_symbols, hover,
1206 position_at_offset, scope_for_left_brace, validate_catalog, validate_position,
1207 };
1208 use crate::{diagnostic::SourcePosition, lexer};
1209
1210 fn catalog_entry(id: &str) -> CompletionCatalogEntry {
1211 CompletionCatalogEntry {
1212 id: id.into(),
1213 label: id.into(),
1214 detail: None,
1215 documentation: None,
1216 }
1217 }
1218
1219 fn semantic_language_source() -> &'static str {
1220 concat!(
1221 "stack 1.0\n\n",
1222 "diagram \"Checkout\" {\n",
1223 " theme dark\n\n",
1224 " node api \"API\" {\n",
1225 " kind service\n",
1226 " icon \"aws:s3\"\n",
1227 " }\n\n",
1228 " group data \"Data\" {\n",
1229 " node database \"Database\" {\n",
1230 " kind database\n",
1231 " }\n",
1232 " }\n\n",
1233 " edge api -> database \"SQL\" {\n",
1234 " kind data\n",
1235 " }\n",
1236 "}\n",
1237 )
1238 }
1239
1240 #[test]
1241 fn diagnostics_echo_the_snapshot_version_for_text_and_bytes() {
1242 let source = "stack 1.0 diagram \"A\" { node a \"A\" }";
1243 let output = diagnostics(source, 42);
1244 assert_eq!(output.schema_version, "1.0");
1245 assert_eq!(output.document_version, 42);
1246 assert!(output.diagnostics.is_empty());
1247
1248 let invalid_utf8 = diagnostics_bytes(&[0xff], 43);
1249 assert_eq!(invalid_utf8.document_version, 43);
1250 assert_eq!(invalid_utf8.diagnostics[0].code, "STK1001");
1251 }
1252
1253 #[test]
1254 fn positions_require_matching_utf8_scalar_coordinates() {
1255 let source = "a馃榾\r\nb";
1256 assert_eq!(
1257 validate_position(
1258 source,
1259 SourcePosition {
1260 byte_offset: 7,
1261 line: 2,
1262 column: 1,
1263 },
1264 ),
1265 Ok(())
1266 );
1267 for position in [
1268 SourcePosition {
1269 byte_offset: 2,
1270 line: 1,
1271 column: 3,
1272 },
1273 SourcePosition {
1274 byte_offset: 6,
1275 line: 1,
1276 column: 3,
1277 },
1278 SourcePosition {
1279 byte_offset: 8,
1280 line: 2,
1281 column: 3,
1282 },
1283 SourcePosition {
1284 byte_offset: 1,
1285 line: 2,
1286 column: 1,
1287 },
1288 ] {
1289 assert_eq!(
1290 validate_position(source, position),
1291 Err(IntelligenceError::InvalidPosition)
1292 );
1293 }
1294 }
1295
1296 #[test]
1297 fn catalog_validation_bounds_and_deduplicates_untrusted_entries() {
1298 let valid = CompletionCatalog {
1299 icons: vec![catalog_entry("database"), catalog_entry("aws:s3")],
1300 };
1301 assert_eq!(validate_catalog(&valid), Ok(()));
1302
1303 let duplicate = CompletionCatalog {
1304 icons: vec![catalog_entry("aws:s3"), catalog_entry("aws:s3")],
1305 };
1306 assert_eq!(
1307 validate_catalog(&duplicate),
1308 Err(IntelligenceError::DuplicateCompletionCatalogId { index: 1 })
1309 );
1310
1311 for id in ["", "AWS:s3", "aws:", "aws:s3:extra", "aws:s_3"] {
1312 let invalid = CompletionCatalog {
1313 icons: vec![catalog_entry(id)],
1314 };
1315 assert_eq!(
1316 validate_catalog(&invalid),
1317 Err(IntelligenceError::InvalidCompletionCatalogEntry { index: 0 })
1318 );
1319 }
1320
1321 let invalid_text = CompletionCatalog {
1322 icons: vec![CompletionCatalogEntry {
1323 id: "aws:s3".into(),
1324 label: String::new(),
1325 detail: Some(String::new()),
1326 documentation: Some("x".repeat(1_001)),
1327 }],
1328 };
1329 assert_eq!(
1330 validate_catalog(&invalid_text),
1331 Err(IntelligenceError::InvalidCompletionCatalogEntry { index: 0 })
1332 );
1333
1334 let too_large = CompletionCatalog {
1335 icons: (0..=MAX_COMPLETION_ICONS)
1336 .map(|index| catalog_entry(&format!("icon-{index}")))
1337 .collect(),
1338 };
1339 assert_eq!(
1340 validate_catalog(&too_large),
1341 Err(IntelligenceError::CompletionCatalogTooLarge)
1342 );
1343 }
1344
1345 #[test]
1346 fn intelligence_errors_have_actionable_display_text() {
1347 let errors = [
1348 IntelligenceError::InvalidPosition,
1349 IntelligenceError::CompletionCatalogTooLarge,
1350 IntelligenceError::InvalidCompletionCatalogEntry { index: 2 },
1351 IntelligenceError::DuplicateCompletionCatalogId { index: 3 },
1352 ];
1353 for error in errors {
1354 assert!(!error.to_string().is_empty());
1355 }
1356 }
1357
1358 #[test]
1359 fn completion_matches_semantic_context_and_caller_catalog() {
1360 let source = semantic_language_source();
1361 let catalog = CompletionCatalog {
1362 icons: vec![
1363 CompletionCatalogEntry {
1364 id: "aws:sqs".into(),
1365 label: "Amazon SQS".into(),
1366 detail: Some("AWS provider icon".into()),
1367 documentation: None,
1368 },
1369 CompletionCatalogEntry {
1370 id: "aws:s3".into(),
1371 label: "Amazon S3".into(),
1372 detail: Some("AWS provider icon".into()),
1373 documentation: None,
1374 },
1375 ],
1376 };
1377
1378 let node_kind = completion(
1379 source,
1380 7,
1381 position_at_offset(source, 78).unwrap_or(SourcePosition::start()),
1382 &CompletionCatalog::default(),
1383 );
1384 assert!(
1385 matches!(node_kind, Ok(ref output) if output.schema_version == "1.0"
1386 && output.document_version == 7
1387 && output.diagnostics.is_empty()
1388 && !output.is_incomplete
1389 && output.items.len() == 1
1390 && output.items[0].label == "service"
1391 && output.items[0].kind == CompletionKind::EnumValue
1392 && output.items[0].sort_text == "0003:service"
1393 && output.items[0].edit.range.start.byte_offset == 74
1394 && output.items[0].edit.range.end.byte_offset == 81)
1395 );
1396
1397 let icons = completion(
1398 source,
1399 7,
1400 position_at_offset(source, 97).unwrap_or(SourcePosition::start()),
1401 &catalog,
1402 );
1403 assert!(
1404 matches!(icons, Ok(ref output) if output.items.iter().map(|item| item.label.as_str()).collect::<Vec<_>>() == ["Amazon S3", "Amazon SQS"]
1405 && output.items.iter().all(|item| item.kind == CompletionKind::Icon)
1406 && output.items[0].edit.new_text == "aws:s3"
1407 && output.items[1].edit.new_text == "aws:sqs")
1408 );
1409
1410 let endpoint = completion(
1411 source,
1412 7,
1413 position_at_offset(source, 206).unwrap_or(SourcePosition::start()),
1414 &CompletionCatalog::default(),
1415 );
1416 assert!(matches!(endpoint, Ok(ref output) if output.items.len() == 1
1417 && output.items[0].label == "database"
1418 && output.items[0].detail.as_deref() == Some("node 路 Database")
1419 && output.items[0].sort_text == "0002:database"));
1420 }
1421
1422 #[test]
1423 fn hover_and_symbols_match_the_portable_semantic_fixture() {
1424 let source = semantic_language_source();
1425 let resolved = hover(
1426 source,
1427 7,
1428 position_at_offset(source, 197).unwrap_or(SourcePosition::start()),
1429 );
1430 assert!(
1431 matches!(resolved, Ok(ref output) if output.schema_version == "1.0"
1432 && output.document_version == 7
1433 && output.diagnostics.is_empty()
1434 && matches!(output.hover, Some(ref value) if value.kind == HoverKind::Node
1435 && value.range.start.byte_offset == 196
1436 && value.range.end.byte_offset == 199
1437 && value.label == "API"
1438 && value.detail.as_deref() == Some("node api 路 service")
1439 && value.documentation.is_none()))
1440 );
1441
1442 let output = document_symbols(source, 7);
1443 assert_eq!(output.schema_version, "1.0");
1444 assert_eq!(output.document_version, 7);
1445 assert!(output.diagnostics.is_empty());
1446 assert_eq!(output.symbols.len(), 1);
1447 let root = &output.symbols[0];
1448 assert_eq!(root.name, "Checkout");
1449 assert_eq!(root.kind, DocumentSymbolKind::Diagram);
1450 assert_eq!(root.detail.as_deref(), Some("Stack 1.0 diagram"));
1451 assert_eq!(root.range.start.byte_offset, 11);
1452 assert_eq!(root.range.end.byte_offset, 239);
1453 assert_eq!(root.selection_range.start.byte_offset, 19);
1454 assert_eq!(root.selection_range.end.byte_offset, 29);
1455 assert_eq!(root.children.len(), 3);
1456
1457 let api = &root.children[0];
1458 assert_eq!(api.name, "API");
1459 assert_eq!(api.kind, DocumentSymbolKind::Node);
1460 assert_eq!(api.detail.as_deref(), Some("node api 路 service"));
1461 assert_eq!(api.range.start.byte_offset, 48);
1462 assert_eq!(api.range.end.byte_offset, 103);
1463
1464 let data = &root.children[1];
1465 assert_eq!(data.name, "Data");
1466 assert_eq!(data.kind, DocumentSymbolKind::Group);
1467 assert_eq!(data.detail.as_deref(), Some("group data"));
1468 assert_eq!(data.children.len(), 1);
1469 assert_eq!(data.children[0].name, "Database");
1470 assert_eq!(data.children[0].kind, DocumentSymbolKind::Node);
1471 assert_eq!(
1472 data.children[0].detail.as_deref(),
1473 Some("node database 路 database")
1474 );
1475 assert_eq!(data.children[0].range.start.byte_offset, 131);
1476 assert_eq!(data.children[0].range.end.byte_offset, 183);
1477
1478 let edge = &root.children[2];
1479 assert_eq!(edge.name, "api -> database");
1480 assert_eq!(edge.kind, DocumentSymbolKind::Edge);
1481 assert_eq!(edge.detail.as_deref(), Some("forward edge 路 data"));
1482 assert_eq!(edge.range.start.byte_offset, 191);
1483 assert_eq!(edge.range.end.byte_offset, 237);
1484 assert_eq!(edge.selection_range.start.byte_offset, 196);
1485 assert_eq!(edge.selection_range.end.byte_offset, 211);
1486 }
1487
1488 #[test]
1489 fn hover_covers_declarations_properties_edges_layout_and_partial_documents() {
1490 let source = concat!(
1491 "stack 1.0\n\n",
1492 "diagram \"System\" {\n",
1493 " theme dark\n",
1494 " node api \"API\" {\n",
1495 " kind service\n",
1496 " icon \"aws:lambda\"\n",
1497 " detail \"HTTP API\"\n",
1498 " }\n",
1499 " group outer \"Outer\" {\n",
1500 " node worker \"Worker\"\n",
1501 " group inner \"Inner\" {\n",
1502 " node db \"Database\" { kind database }\n",
1503 " }\n",
1504 " layout { direction right rank same [worker, db] order [inner, db] }\n",
1505 " }\n",
1506 " edge api <-> worker \"Events\" { kind event }\n",
1507 " edge worker -- db\n",
1508 " layout { direction down rank same [api, outer] order [outer, api] }\n",
1509 "}\n",
1510 );
1511 let cases = [
1512 ("\"System\"", 1, HoverKind::Diagram, "System"),
1513 ("dark", 1, HoverKind::Property, "dark"),
1514 ("api \"API\"", 1, HoverKind::Node, "API"),
1515 ("\"API\"", 1, HoverKind::Node, "API"),
1516 ("service", 1, HoverKind::Property, "service"),
1517 ("aws:lambda", 1, HoverKind::Property, "aws:lambda"),
1518 ("HTTP API", 1, HoverKind::Property, "HTTP API"),
1519 ("outer \"Outer\"", 1, HoverKind::Group, "Outer"),
1520 ("\"Inner\"", 1, HoverKind::Group, "Inner"),
1521 ("right", 1, HoverKind::Property, "right"),
1522 ("worker, db", 1, HoverKind::Node, "Worker"),
1523 ("inner, db", 1, HoverKind::Group, "Inner"),
1524 ("<->", 1, HoverKind::Edge, "Events"),
1525 ("\"Events\"", 1, HoverKind::Edge, "Events"),
1526 ("event", 1, HoverKind::Property, "event"),
1527 ("--", 1, HoverKind::Edge, "worker -- db"),
1528 ("down", 1, HoverKind::Property, "down"),
1529 ("api, outer", 1, HoverKind::Node, "API"),
1530 ("outer, api", 1, HoverKind::Group, "Outer"),
1531 ];
1532 for (needle, delta, kind, label) in cases {
1533 let byte_offset = source.find(needle).map_or(0, |offset| offset + delta);
1534 let output = hover(
1535 source,
1536 21,
1537 position_at_offset(source, byte_offset).unwrap_or(SourcePosition::start()),
1538 );
1539 assert!(
1540 matches!(output, Ok(ref value) if matches!(value.hover, Some(ref item) if item.kind == kind && item.label == label))
1541 );
1542 }
1543
1544 let invalid_position = hover(
1545 source,
1546 21,
1547 SourcePosition {
1548 byte_offset: 1,
1549 line: 99,
1550 column: 99,
1551 },
1552 );
1553 assert_eq!(invalid_position, Err(IntelligenceError::InvalidPosition));
1554
1555 let syntax_invalid = "stack 1.0 diagram \"Partial\" { node api";
1556 let unresolved = hover(
1557 syntax_invalid,
1558 22,
1559 position_at_offset(syntax_invalid, 34).unwrap_or(SourcePosition::start()),
1560 );
1561 assert!(
1562 matches!(unresolved, Ok(ref output) if output.hover.is_none()
1563 && !output.diagnostics.is_empty())
1564 );
1565 let no_symbols = document_symbols(syntax_invalid, 22);
1566 assert!(no_symbols.symbols.is_empty());
1567 assert!(!no_symbols.diagnostics.is_empty());
1568
1569 let semantic_invalid =
1570 "stack 1.0 diagram \"Duplicate\" { node same \"A\" node same \"B\" }";
1571 let symbols = document_symbols(semantic_invalid, 23);
1572 assert_eq!(symbols.symbols.len(), 1);
1573 assert!(!symbols.diagnostics.is_empty());
1574 }
1575
1576 #[test]
1577 fn completion_recovers_a_partial_node_property() {
1578 let source = "stack 1.0\n\ndiagram \"Partial\" {\n node api \"API\" {\n ki\n";
1579 let output = completion(
1580 source,
1581 9,
1582 position_at_offset(source, 56).unwrap_or(SourcePosition::start()),
1583 &CompletionCatalog::default(),
1584 );
1585 assert!(matches!(output, Ok(ref value) if value.is_incomplete
1586 && value.diagnostics.len() == 1
1587 && value.diagnostics[0].code == "STK2002"
1588 && value.items.len() == 1
1589 && value.items[0].label == "kind"
1590 && value.items[0].kind == CompletionKind::Property
1591 && value.items[0].edit.range.start.byte_offset == 54
1592 && value.items[0].edit.range.end.byte_offset == 56));
1593 }
1594
1595 #[test]
1596 fn completion_rejects_boundary_errors_and_stops_after_lexical_failure() {
1597 let source = "stack 1.0 diagram \"A\" { node a \"A\" }";
1598 let invalid_position = completion(
1599 source,
1600 1,
1601 SourcePosition {
1602 byte_offset: 1,
1603 line: 9,
1604 column: 9,
1605 },
1606 &CompletionCatalog::default(),
1607 );
1608 assert_eq!(invalid_position, Err(IntelligenceError::InvalidPosition));
1609
1610 let invalid_catalog = completion(
1611 source,
1612 1,
1613 SourcePosition::start(),
1614 &CompletionCatalog {
1615 icons: vec![catalog_entry("INVALID")],
1616 },
1617 );
1618 assert_eq!(
1619 invalid_catalog,
1620 Err(IntelligenceError::InvalidCompletionCatalogEntry { index: 0 })
1621 );
1622
1623 let lexical_source = "\u{feff}stack";
1624 let lexical = completion(
1625 lexical_source,
1626 2,
1627 SourcePosition::start(),
1628 &CompletionCatalog::default(),
1629 );
1630 assert!(matches!(lexical, Ok(ref output) if output.is_incomplete
1631 && output.items.is_empty()
1632 && output.diagnostics[0].code == "STK1002"));
1633 }
1634
1635 #[test]
1636 fn every_completion_category_has_a_deterministic_candidate_shape() {
1637 let catalog = CompletionCatalog {
1638 icons: vec![CompletionCatalogEntry {
1639 id: "aws:s3".into(),
1640 label: "Amazon S3".into(),
1641 detail: Some("Object storage".into()),
1642 documentation: Some("Caller-owned documentation".into()),
1643 }],
1644 };
1645 let nodes = vec![
1646 ("api".into(), "API".into()),
1647 ("db".into(), "Database".into()),
1648 ];
1649 let contexts = [
1650 CompletionContext::Root,
1651 CompletionContext::DiagramMember,
1652 CompletionContext::GroupMember,
1653 CompletionContext::NodeProperty,
1654 CompletionContext::EdgeProperty,
1655 CompletionContext::LayoutStatement,
1656 CompletionContext::NodeKind,
1657 CompletionContext::EdgeKind,
1658 CompletionContext::Direction,
1659 CompletionContext::RankRelation,
1660 CompletionContext::Icon,
1661 CompletionContext::EdgeEndpoint {
1662 excluded: Some("api".into()),
1663 },
1664 CompletionContext::EdgeEndpoint { excluded: None },
1665 ];
1666 for context in contexts {
1667 let candidates = candidates_for(context, &catalog, &nodes);
1668 assert!(!candidates.is_empty());
1669 assert!(
1670 candidates
1671 .iter()
1672 .all(|candidate| !candidate.filter_text.is_empty())
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn scope_detection_distinguishes_every_braced_construct() {
1679 let source = concat!(
1680 "stack 1.0 diagram \"D\" {",
1681 "node n \"N\" { kind service }",
1682 "group g \"G\" { node c \"C\" }",
1683 "edge n -> c \"E\" { kind flow }",
1684 "layout { direction right }",
1685 "}",
1686 );
1687 let tokens = lexer::tokenize(source).unwrap_or_default();
1688 let scopes: Vec<_> = tokens
1689 .iter()
1690 .enumerate()
1691 .filter(|(_, token)| matches!(token.kind, crate::lexer::TokenKind::LeftBrace))
1692 .filter_map(|(index, _)| scope_for_left_brace(&tokens, index))
1693 .collect();
1694 assert_eq!(
1695 scopes,
1696 [
1697 Scope::Diagram,
1698 Scope::Node,
1699 Scope::Group,
1700 Scope::Edge,
1701 Scope::Layout,
1702 ]
1703 );
1704 }
1705}