Skip to main content

stack_compiler/
language_intelligence.rs

1//! Stateless, protocol-neutral language intelligence for one source snapshot.
2
3use 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
12/// Portable language-intelligence schema version implemented by this crate.
13pub const SCHEMA_VERSION: &str = "1.0";
14
15/// Maximum number of caller-owned icons accepted for one completion request.
16pub const MAX_COMPLETION_ICONS: usize = 4_096;
17
18/// Caller-owned icon metadata available to completion.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CompletionCatalogEntry {
21    /// Exact unnamespaced or provider-namespaced Stack icon identifier.
22    pub id: String,
23    /// User-visible plain-text label.
24    pub label: String,
25    /// Optional plain-text secondary label.
26    pub detail: Option<String>,
27    /// Optional plain-text documentation.
28    pub documentation: Option<String>,
29}
30
31/// Explicit, caller-owned catalog data for one completion request.
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct CompletionCatalog {
34    /// Available icon entries. The compiler never loads these from storage or a network.
35    pub icons: Vec<CompletionCatalogEntry>,
36}
37
38/// A source replacement interpreted against the unchanged input snapshot.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TextEdit {
41    /// End-exclusive source range replaced by this edit.
42    pub range: Span,
43    /// Literal Stack source inserted in place of the range.
44    pub new_text: String,
45}
46
47/// Semantic category of one completion item.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CompletionKind {
50    /// A grammatical Stack keyword.
51    Keyword,
52    /// A property or layout statement valid in the current block.
53    Property,
54    /// A closed value from the Stack language specification.
55    EnumValue,
56    /// A document-local semantic identifier.
57    Identifier,
58    /// An icon supplied by the caller-owned completion catalog.
59    Icon,
60}
61
62/// One literal, protocol-neutral source completion.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CompletionItem {
65    /// User-visible plain-text label.
66    pub label: String,
67    /// Semantic completion category.
68    pub kind: CompletionKind,
69    /// Optional plain-text secondary label.
70    pub detail: Option<String>,
71    /// Optional plain-text documentation.
72    pub documentation: Option<String>,
73    /// Plain string used by consumers for filtering.
74    pub filter_text: String,
75    /// Stable ordering key.
76    pub sort_text: String,
77    /// Literal source replacement for this item.
78    pub edit: TextEdit,
79}
80
81/// Completion result for one caller-owned document version.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct CompletionOutput {
84    /// Portable schema version.
85    pub schema_version: &'static str,
86    /// Document version supplied by the caller.
87    pub document_version: u64,
88    /// Ordered compiler diagnostics for the same source snapshot.
89    pub diagnostics: Vec<Diagnostic>,
90    /// Whether more source context may materially change the list.
91    pub is_incomplete: bool,
92    /// Deterministically ordered completion items.
93    pub items: Vec<CompletionItem>,
94}
95
96/// Semantic category described by hover information.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum HoverKind {
99    /// The document's diagram declaration.
100    Diagram,
101    /// A containment group.
102    Group,
103    /// A node declaration or reference.
104    Node,
105    /// An edge declaration.
106    Edge,
107    /// A language property, theme, or layout value.
108    Property,
109}
110
111/// Plain-text semantic information for one source token.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Hover {
114    /// Exact source range described by this hover.
115    pub range: Span,
116    /// Semantic category.
117    pub kind: HoverKind,
118    /// Short user-visible label.
119    pub label: String,
120    /// Optional plain-text secondary label.
121    pub detail: Option<String>,
122    /// Optional plain-text documentation.
123    pub documentation: Option<String>,
124}
125
126/// Hover result for one caller-owned document version.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct HoverOutput {
129    /// Portable schema version.
130    pub schema_version: &'static str,
131    /// Document version supplied by the caller.
132    pub document_version: u64,
133    /// Ordered compiler diagnostics for the same source snapshot.
134    pub diagnostics: Vec<Diagnostic>,
135    /// Resolved hover, or `None` when no trustworthy construct covers the position.
136    pub hover: Option<Hover>,
137}
138
139/// Semantic category of a document symbol.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum DocumentSymbolKind {
142    /// The document's diagram declaration.
143    Diagram,
144    /// A containment group.
145    Group,
146    /// A node declaration.
147    Node,
148    /// A diagram-scope edge declaration.
149    Edge,
150}
151
152/// One hierarchical source declaration.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct DocumentSymbol {
155    /// User-visible label or concise edge description.
156    pub name: String,
157    /// Semantic symbol category.
158    pub kind: DocumentSymbolKind,
159    /// Optional stable plain-text secondary information.
160    pub detail: Option<String>,
161    /// Complete declaration range.
162    pub range: Span,
163    /// Most useful authored token within the declaration.
164    pub selection_range: Span,
165    /// Directly nested declarations in source order.
166    pub children: Vec<DocumentSymbol>,
167}
168
169/// Hierarchical document symbols for one caller-owned document version.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct DocumentSymbolsOutput {
172    /// Portable schema version.
173    pub schema_version: &'static str,
174    /// Document version supplied by the caller.
175    pub document_version: u64,
176    /// Ordered compiler diagnostics for the same source snapshot.
177    pub diagnostics: Vec<Diagnostic>,
178    /// Root diagram symbol, absent when syntax parsing fails.
179    pub symbols: Vec<DocumentSymbol>,
180}
181
182/// Compiler diagnostics for one caller-owned document version.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct DiagnosticsOutput {
185    /// Portable schema version.
186    pub schema_version: &'static str,
187    /// Document version supplied by the caller.
188    pub document_version: u64,
189    /// Ordered compiler diagnostics.
190    pub diagnostics: Vec<Diagnostic>,
191}
192
193/// Operational failure at the language-intelligence input boundary.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum IntelligenceError {
196    /// The supplied source position is outside the source or its coordinates disagree.
197    InvalidPosition,
198    /// The caller supplied more icon entries than the bounded contract permits.
199    CompletionCatalogTooLarge,
200    /// One catalog entry violates the portable identifier or text bounds.
201    InvalidCompletionCatalogEntry {
202        /// Zero-based catalog entry index.
203        index: usize,
204    },
205    /// An icon identifier occurs more than once in the catalog.
206    DuplicateCompletionCatalogId {
207        /// Zero-based index of the repeated entry.
208        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
234/// Compiles UTF-8 Stack source and returns portable diagnostics for one snapshot.
235pub fn diagnostics(source: &str, document_version: u64) -> DiagnosticsOutput {
236    diagnostics_bytes(source.as_bytes(), document_version)
237}
238
239/// Decodes and compiles Stack source bytes for one snapshot.
240pub 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
248/// Computes context-aware completion for one complete UTF-8 source snapshot.
249pub 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
303/// Resolves plain-text semantic hover for one complete UTF-8 source snapshot.
304pub 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
324/// Builds hierarchical symbols for one complete UTF-8 source snapshot.
325pub 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}