1use 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
11pub const LANGUAGE_INTELLIGENCE_SCHEMA_VERSION: &str = compiler_language::SCHEMA_VERSION;
13
14impl Engine<'_> {
15 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 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#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct TextEdit {
140 pub range: SourceRange,
142 pub new_text: String,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum CompletionKind {
149 Keyword,
151 Property,
153 EnumValue,
155 Identifier,
157 Icon,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct CompletionItem {
164 pub label: String,
166 pub kind: CompletionKind,
168 pub detail: Option<String>,
170 pub documentation: Option<String>,
172 pub filter_text: String,
174 pub sort_text: String,
176 pub edit: TextEdit,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct CompletionOutput {
183 pub schema_version: String,
185 pub document_version: u64,
187 pub diagnostics: Vec<Diagnostic>,
189 pub is_incomplete: bool,
191 pub items: Vec<CompletionItem>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum HoverKind {
198 Diagram,
200 Group,
202 Node,
204 Edge,
206 Property,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Hover {
213 pub range: SourceRange,
215 pub kind: HoverKind,
217 pub label: String,
219 pub detail: Option<String>,
221 pub documentation: Option<String>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct HoverOutput {
228 pub schema_version: String,
230 pub document_version: u64,
232 pub diagnostics: Vec<Diagnostic>,
234 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}