Skip to main content

stack_engine/
language.rs

1//! Theme-aware adapter for protocol-neutral compiler language intelligence.
2
3use std::collections::BTreeMap;
4
5use stack_compiler::{
6    diagnostic as compiler_diagnostic, language_intelligence as compiler_language,
7};
8
9use crate::{Diagnostic, Engine, OperationResult, OperationalError, SourcePosition, SourceRange};
10
11/// Portable language-intelligence schema version implemented by the pinned compiler.
12pub const LANGUAGE_INTELLIGENCE_SCHEMA_VERSION: &str = compiler_language::SCHEMA_VERSION;
13
14impl Engine<'_> {
15    /// Computes context-aware completion from one complete UTF-8 source snapshot.
16    ///
17    /// The response echoes `document_version` so a host can discard stale work.
18    /// Core and caller-owned provider icons come from the same validated catalogs
19    /// used by check and render operations.
20    pub fn completion(
21        &self,
22        source: &str,
23        document_version: u64,
24        position: SourcePosition,
25    ) -> OperationResult<CompletionOutput> {
26        let catalog = self.completion_catalog()?;
27        compiler_language::completion(
28            source,
29            document_version,
30            compiler_diagnostic::SourcePosition::try_from(position)?,
31            &catalog,
32        )
33        .map(CompletionOutput::from)
34        .map_err(language_intelligence_error)
35    }
36
37    /// Resolves plain-text semantic hover for one complete UTF-8 source snapshot.
38    ///
39    /// The response echoes `document_version` so a host can discard stale work.
40    pub fn hover(
41        &self,
42        source: &str,
43        document_version: u64,
44        position: SourcePosition,
45    ) -> OperationResult<HoverOutput> {
46        compiler_language::hover(
47            source,
48            document_version,
49            compiler_diagnostic::SourcePosition::try_from(position)?,
50        )
51        .map(HoverOutput::from)
52        .map_err(language_intelligence_error)
53    }
54
55    fn completion_catalog(&self) -> OperationResult<compiler_language::CompletionCatalog> {
56        let mut icons = BTreeMap::new();
57        for theme in &self.catalog.themes {
58            for icon in &theme.icons {
59                icons.entry(icon.id.clone()).or_insert_with(|| {
60                    compiler_language::CompletionCatalogEntry {
61                        id: icon.id.clone(),
62                        label: icon.id.clone(),
63                        detail: Some(icon.subject.clone()),
64                        documentation: icon.description.clone(),
65                    }
66                });
67            }
68        }
69        for pack in self.provider_packs {
70            let manifest = pack.manifest();
71            for icon in &manifest.icons {
72                icons.insert(
73                    icon.id.clone(),
74                    compiler_language::CompletionCatalogEntry {
75                        id: icon.id.clone(),
76                        label: icon.id.clone(),
77                        detail: Some(icon.product_name.clone()),
78                        documentation: Some(format!(
79                            "{} provider icon: {}",
80                            manifest.provider.name, icon.subject
81                        )),
82                    },
83                );
84            }
85        }
86        if icons.len() > compiler_language::MAX_COMPLETION_ICONS {
87            return Err(OperationalError::InvalidLanguageIntelligenceInput {
88                reason: "completion catalog exceeds the item limit",
89            });
90        }
91        Ok(compiler_language::CompletionCatalog {
92            icons: icons.into_values().collect(),
93        })
94    }
95}
96
97fn language_intelligence_error(error: compiler_language::IntelligenceError) -> OperationalError {
98    let reason = match error {
99        compiler_language::IntelligenceError::InvalidPosition => "source position is invalid",
100        compiler_language::IntelligenceError::CompletionCatalogTooLarge => {
101            "completion catalog exceeds the item limit"
102        }
103        compiler_language::IntelligenceError::InvalidCompletionCatalogEntry { .. } => {
104            "completion catalog contains an invalid entry"
105        }
106        compiler_language::IntelligenceError::DuplicateCompletionCatalogId { .. } => {
107            "completion catalog contains a duplicate icon id"
108        }
109    };
110    OperationalError::InvalidLanguageIntelligenceInput { reason }
111}
112
113impl TryFrom<SourcePosition> for compiler_diagnostic::SourcePosition {
114    type Error = OperationalError;
115
116    fn try_from(position: SourcePosition) -> Result<Self, Self::Error> {
117        Ok(Self {
118            byte_offset: usize::try_from(position.byte_offset).map_err(|_| {
119                OperationalError::InvalidLanguageIntelligenceInput {
120                    reason: "source position exceeds the target address space",
121                }
122            })?,
123            line: usize::try_from(position.line).map_err(|_| {
124                OperationalError::InvalidLanguageIntelligenceInput {
125                    reason: "source position exceeds the target address space",
126                }
127            })?,
128            column: usize::try_from(position.column).map_err(|_| {
129                OperationalError::InvalidLanguageIntelligenceInput {
130                    reason: "source position exceeds the target address space",
131                }
132            })?,
133        })
134    }
135}
136
137/// A source replacement interpreted against the unchanged input snapshot.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct TextEdit {
140    /// End-exclusive source range replaced by this edit.
141    pub range: SourceRange,
142    /// Literal Stack source inserted in place of the range.
143    pub new_text: String,
144}
145
146/// Semantic category of one completion item.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum CompletionKind {
149    /// A grammatical Stack keyword.
150    Keyword,
151    /// A property or layout statement valid in the current block.
152    Property,
153    /// A closed value from the Stack language specification.
154    EnumValue,
155    /// A document-local semantic identifier.
156    Identifier,
157    /// An icon from the engine's core or caller-owned provider catalog.
158    Icon,
159}
160
161/// One literal, protocol-neutral source completion.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct CompletionItem {
164    /// User-visible plain-text label.
165    pub label: String,
166    /// Semantic completion category.
167    pub kind: CompletionKind,
168    /// Optional plain-text secondary label.
169    pub detail: Option<String>,
170    /// Optional plain-text documentation.
171    pub documentation: Option<String>,
172    /// Plain string used by consumers for filtering.
173    pub filter_text: String,
174    /// Stable ordering key.
175    pub sort_text: String,
176    /// Literal source replacement for this item.
177    pub edit: TextEdit,
178}
179
180/// Completion result for one caller-owned document version.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct CompletionOutput {
183    /// Portable language-intelligence schema version.
184    pub schema_version: String,
185    /// Document version supplied by the caller.
186    pub document_version: u64,
187    /// Ordered compiler diagnostics for the same source snapshot.
188    pub diagnostics: Vec<Diagnostic>,
189    /// Whether more source context may materially change the list.
190    pub is_incomplete: bool,
191    /// Deterministically ordered completion items.
192    pub items: Vec<CompletionItem>,
193}
194
195/// Semantic category described by hover information.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum HoverKind {
198    /// The document's diagram declaration.
199    Diagram,
200    /// A containment group.
201    Group,
202    /// A node declaration or reference.
203    Node,
204    /// An edge declaration.
205    Edge,
206    /// A language property, theme, or layout value.
207    Property,
208}
209
210/// Plain-text semantic information for one source token.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Hover {
213    /// Exact source range described by this hover.
214    pub range: SourceRange,
215    /// Semantic category.
216    pub kind: HoverKind,
217    /// Short user-visible label.
218    pub label: String,
219    /// Optional plain-text secondary label.
220    pub detail: Option<String>,
221    /// Optional plain-text documentation.
222    pub documentation: Option<String>,
223}
224
225/// Hover result for one caller-owned document version.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct HoverOutput {
228    /// Portable language-intelligence schema version.
229    pub schema_version: String,
230    /// Document version supplied by the caller.
231    pub document_version: u64,
232    /// Ordered compiler diagnostics for the same source snapshot.
233    pub diagnostics: Vec<Diagnostic>,
234    /// Resolved semantic hover, if a trustworthy construct covers the position.
235    pub hover: Option<Hover>,
236}
237
238impl From<compiler_language::CompletionOutput> for CompletionOutput {
239    fn from(output: compiler_language::CompletionOutput) -> Self {
240        Self {
241            schema_version: output.schema_version.to_owned(),
242            document_version: output.document_version,
243            diagnostics: output
244                .diagnostics
245                .into_iter()
246                .map(Diagnostic::from)
247                .collect(),
248            is_incomplete: output.is_incomplete,
249            items: output.items.into_iter().map(CompletionItem::from).collect(),
250        }
251    }
252}
253
254impl From<compiler_language::CompletionItem> for CompletionItem {
255    fn from(item: compiler_language::CompletionItem) -> Self {
256        Self {
257            label: item.label,
258            kind: CompletionKind::from(item.kind),
259            detail: item.detail,
260            documentation: item.documentation,
261            filter_text: item.filter_text,
262            sort_text: item.sort_text,
263            edit: TextEdit {
264                range: SourceRange::from(item.edit.range),
265                new_text: item.edit.new_text,
266            },
267        }
268    }
269}
270
271impl From<compiler_language::CompletionKind> for CompletionKind {
272    fn from(kind: compiler_language::CompletionKind) -> Self {
273        match kind {
274            compiler_language::CompletionKind::Keyword => Self::Keyword,
275            compiler_language::CompletionKind::Property => Self::Property,
276            compiler_language::CompletionKind::EnumValue => Self::EnumValue,
277            compiler_language::CompletionKind::Identifier => Self::Identifier,
278            compiler_language::CompletionKind::Icon => Self::Icon,
279        }
280    }
281}
282
283impl From<compiler_language::HoverOutput> for HoverOutput {
284    fn from(output: compiler_language::HoverOutput) -> Self {
285        Self {
286            schema_version: output.schema_version.to_owned(),
287            document_version: output.document_version,
288            diagnostics: output
289                .diagnostics
290                .into_iter()
291                .map(Diagnostic::from)
292                .collect(),
293            hover: output.hover.map(Hover::from),
294        }
295    }
296}
297
298impl From<compiler_language::Hover> for Hover {
299    fn from(hover: compiler_language::Hover) -> Self {
300        Self {
301            range: SourceRange::from(hover.range),
302            kind: HoverKind::from(hover.kind),
303            label: hover.label,
304            detail: hover.detail,
305            documentation: hover.documentation,
306        }
307    }
308}
309
310impl From<compiler_language::HoverKind> for HoverKind {
311    fn from(kind: compiler_language::HoverKind) -> Self {
312        match kind {
313            compiler_language::HoverKind::Diagram => Self::Diagram,
314            compiler_language::HoverKind::Group => Self::Group,
315            compiler_language::HoverKind::Node => Self::Node,
316            compiler_language::HoverKind::Edge => Self::Edge,
317            compiler_language::HoverKind::Property => Self::Property,
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use std::error::Error;
325
326    use super::{CompletionKind, HoverKind, language_intelligence_error};
327    use crate::{Engine, OperationalError, ProviderAsset, ProviderPack, SourcePosition};
328
329    fn position(source: &str, byte_offset: usize) -> SourcePosition {
330        let mut line = 1_u64;
331        let mut column = 1_u64;
332        for character in source[..byte_offset].chars() {
333            if character == '\n' {
334                line += 1;
335                column = 1;
336            } else {
337                column += 1;
338            }
339        }
340        SourcePosition {
341            byte_offset: byte_offset as u64,
342            line,
343            column,
344        }
345    }
346
347    #[test]
348    fn completion_uses_context_and_echoes_the_document_version() -> Result<(), Box<dyn Error>> {
349        let source = "stack 1.0\ndiagram \"Draft\" {\n  no\n}\n";
350        let cursor = source.find("no").ok_or("missing prefix")? + 2;
351        let output = Engine::bundled().completion(source, 42, position(source, cursor))?;
352        assert_eq!(output.schema_version, "1.0");
353        assert_eq!(output.document_version, 42);
354        assert!(output.is_incomplete);
355        assert_eq!(output.items.len(), 1);
356        assert_eq!(output.items[0].label, "node");
357        assert_eq!(output.items[0].kind, CompletionKind::Keyword);
358        assert_eq!(output.items[0].edit.new_text, "node");
359        Ok(())
360    }
361
362    #[test]
363    fn completion_discovers_core_icon_ids() -> Result<(), Box<dyn Error>> {
364        let source = "stack 1.0 diagram \"Icons\" { node api \"API\" { icon \"ga\" } }";
365        let cursor = source.find("ga").ok_or("missing icon prefix")? + 2;
366        let output = Engine::bundled().completion(source, 3, position(source, cursor))?;
367        assert_eq!(output.items.len(), 1);
368        let item = &output.items[0];
369        assert_eq!(item.label, "gateway");
370        assert_eq!(item.filter_text, "gateway");
371        assert_eq!(item.kind, CompletionKind::Icon);
372        assert_eq!(item.detail.as_deref(), Some("Network gateway"));
373        assert_eq!(item.edit.new_text, "gateway");
374        Ok(())
375    }
376
377    #[test]
378    fn completion_includes_validated_provider_icons() -> Result<(), Box<dyn Error>> {
379        let fixture: serde_json::Value =
380            serde_json::from_str(include_str!("../tests/fixtures/provider-pack-input.json"))?;
381        let input = fixture
382            .as_array()
383            .and_then(|items| items.first())
384            .ok_or("missing provider fixture")?;
385        let manifest: stack_theme::ProviderPack =
386            serde_json::from_value(input.get("manifest").cloned().ok_or("missing manifest")?)?;
387        let assets = input
388            .get("assets")
389            .and_then(serde_json::Value::as_array)
390            .ok_or("missing assets")?
391            .iter()
392            .map(|asset| {
393                ProviderAsset::new(
394                    asset
395                        .get("path")
396                        .and_then(serde_json::Value::as_str)
397                        .unwrap_or_default(),
398                    asset
399                        .get("svg")
400                        .and_then(serde_json::Value::as_str)
401                        .unwrap_or_default(),
402                )
403            })
404            .collect();
405        let pack = ProviderPack::new(manifest, assets)?;
406        let packs = [pack];
407        let engine = Engine::with_provider_packs(&packs)?;
408        let source =
409            "stack 1.0 diagram \"Provider\" { node store \"Store\" { icon \"example:s\" } }";
410        let cursor = source.find("example:s").ok_or("missing icon prefix")? + "example:s".len();
411        let output = engine.completion(source, 9, position(source, cursor))?;
412        assert_eq!(output.items.len(), 1);
413        assert_eq!(output.items[0].label, "example:storage");
414        assert_eq!(output.items[0].detail.as_deref(), Some("Example Storage"));
415        assert_eq!(
416            output.items[0].documentation.as_deref(),
417            Some("Example Cloud provider icon: Object storage service")
418        );
419        Ok(())
420    }
421
422    #[test]
423    fn hover_resolves_semantics_and_preserves_exact_ranges() -> Result<(), Box<dyn Error>> {
424        let source = "stack 1.0 diagram \"API\" { node api \"Public API\" edge api -> client node client \"Client\" }";
425        let reference = source.find("edge api").ok_or("missing edge")? + "edge ".len();
426        let output = Engine::bundled().hover(source, 11, position(source, reference))?;
427        assert_eq!(output.schema_version, "1.0");
428        assert_eq!(output.document_version, 11);
429        let hover = output.hover.ok_or("missing hover")?;
430        assert_eq!(hover.kind, HoverKind::Node);
431        assert_eq!(hover.label, "Public API");
432        assert_eq!(hover.detail.as_deref(), Some("node api ยท service"));
433        assert_eq!(hover.range.start.byte_offset, reference as u64);
434        assert_eq!(hover.range.end.byte_offset, (reference + 3) as u64);
435        Ok(())
436    }
437
438    #[test]
439    fn invalid_position_uses_the_operational_error_channel() {
440        let result = Engine::bundled().completion(
441            "stack 1.0",
442            1,
443            SourcePosition {
444                byte_offset: 4,
445                line: 9,
446                column: 9,
447            },
448        );
449        assert!(matches!(
450            result,
451            Err(OperationalError::InvalidLanguageIntelligenceInput {
452                reason: "source position is invalid"
453            })
454        ));
455    }
456
457    #[test]
458    fn catalog_limits_and_validation_use_the_operational_error_channel()
459    -> Result<(), Box<dyn Error>> {
460        let source = "stack 1.0 diagram \"Icons\" { node api \"API\" { icon \"\" } }";
461        let cursor = source.find("\"\"").ok_or("missing empty icon")? + 1;
462        let mut oversized = stack_theme::catalog().clone();
463        let template = oversized.themes[0].icons[0].clone();
464        for index in 0..=stack_compiler::language_intelligence::MAX_COMPLETION_ICONS {
465            let mut icon = template.clone();
466            icon.id = format!("extra-{index}");
467            oversized.themes[0].icons.push(icon);
468        }
469        let engine = Engine::with_catalog(&oversized, stack_theme::CATALOG_REVISION)?;
470        assert!(matches!(
471            engine.completion(source, 1, position(source, cursor)),
472            Err(OperationalError::InvalidLanguageIntelligenceInput {
473                reason: "completion catalog exceeds the item limit"
474            })
475        ));
476
477        let mut invalid = stack_theme::catalog().clone();
478        invalid.themes[0].icons[0].id = "INVALID".to_owned();
479        let engine = Engine::with_catalog(&invalid, stack_theme::CATALOG_REVISION)?;
480        assert!(matches!(
481            engine.completion(source, 1, position(source, cursor)),
482            Err(OperationalError::InvalidLanguageIntelligenceInput {
483                reason: "completion catalog contains an invalid entry"
484            })
485        ));
486        Ok(())
487    }
488
489    #[test]
490    fn compiler_catalog_errors_have_stable_engine_messages() {
491        use stack_compiler::language_intelligence::IntelligenceError;
492
493        assert_eq!(
494            language_intelligence_error(IntelligenceError::CompletionCatalogTooLarge).to_string(),
495            "invalid language-intelligence input: completion catalog exceeds the item limit"
496        );
497        assert_eq!(
498            language_intelligence_error(IntelligenceError::DuplicateCompletionCatalogId {
499                index: 1,
500            })
501            .to_string(),
502            "invalid language-intelligence input: completion catalog contains a duplicate icon id"
503        );
504    }
505}