ailloli_ui_editor/code/semantic.rs
1//! UI-agnostic LSP enrichment and JSON SCIP import models.
2
3use std::ops::Range;
4
5use crate::code::{
6 CodeFileSummary, CodeSymbol, Diagnostic, DiagnosticSeverity, Document, DocumentId,
7 DocumentVersion, EditorLanguage, SymbolEdge, SymbolEdgeKind, SymbolId, SymbolKind,
8 SymbolSource,
9};
10
11/// Opaque caller/backend request identifier used for cancellation.
12///
13/// Zero is valid and allocation policy belongs to the backend.
14///
15/// # Examples
16///
17/// ```
18/// use ailloli_ui_editor::LspRequestId;
19/// assert_eq!(LspRequestId(42).0, 42);
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
22pub struct LspRequestId(pub u64);
23
24/// Feature set advertised synchronously by an LSP backend.
25///
26/// Every capability defaults to `false`; callers should not invoke optional
27/// query methods unless the corresponding flag is true.
28///
29/// # Examples
30///
31/// ```
32/// use ailloli_ui_editor::LspCapabilities;
33/// let capabilities = LspCapabilities::default();
34/// assert!(!capabilities.document_symbols && !capabilities.diagnostics);
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
37pub struct LspCapabilities {
38 /// Backend can return document symbols.
39 pub document_symbols: bool,
40 /// Backend supports definition lookup outside this minimal trait surface.
41 pub definitions: bool,
42 /// Backend can return semantic references.
43 pub references: bool,
44 /// Backend can return diagnostics.
45 pub diagnostics: bool,
46 /// Backend supports hover outside this minimal trait surface.
47 pub hover: bool,
48 /// Backend supports semantic tokens outside this minimal trait surface.
49 pub semantic_tokens: bool,
50}
51
52/// Typed failure from an LSP backend or adapter boundary.
53///
54/// # Examples
55///
56/// ```
57/// use ailloli_ui_editor::{LspError, LspRequestId};
58/// assert_eq!(LspError::RequestCancelled(LspRequestId(1)), LspError::RequestCancelled(LspRequestId(1)));
59/// ```
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub enum LspError {
62 /// No backend is configured or reachable.
63 BackendUnavailable,
64 /// A named optional operation was not advertised or implemented.
65 CapabilityUnavailable(&'static str),
66 /// The specified request was cancelled.
67 RequestCancelled(LspRequestId),
68 /// Protocol/response validation failure with an owned explanation.
69 Protocol(String),
70 /// Transport or operating-system I/O failure with an owned explanation.
71 Io(String),
72}
73
74/// Synchronous, UI-agnostic interface for optional language-server enrichment.
75///
76/// Lifecycle methods default to success, cancellation defaults to a typed
77/// cancellation error, and query methods default to capability errors. Backends
78/// own transport, queuing, concurrency, timeouts, and request-ID allocation.
79///
80/// # Examples
81///
82/// ```
83/// use ailloli_ui_editor::{LspBackend, NoopLspBackend};
84/// let backend = NoopLspBackend;
85/// assert_eq!(backend.capabilities().document_symbols, false);
86/// ```
87pub trait LspBackend {
88 /// Returns the backend's currently advertised optional operations.
89 ///
90 /// # Examples
91 ///
92 /// ```
93 /// use ailloli_ui_editor::{LspBackend, NoopLspBackend};
94 /// assert_eq!(NoopLspBackend.capabilities().diagnostics, false);
95 /// ```
96 fn capabilities(&self) -> LspCapabilities;
97
98 /// Notifies the backend that a document opened.
99 ///
100 /// The default performs no work and returns `Ok(())` regardless of document
101 /// contents or metadata.
102 ///
103 /// # Errors
104 ///
105 /// The default implementation never fails. A backend override may return a
106 /// categorized [`LspError`] for transport, protocol, availability, or I/O
107 /// failure.
108 ///
109 /// # Examples
110 ///
111 /// ```
112 /// use ailloli_ui_editor::{Document, DocumentId, LspBackend, NoopLspBackend};
113 /// use ailloli_ui_text::TextBuffer;
114 /// let document = Document::new(DocumentId(1), TextBuffer::new());
115 /// assert!(NoopLspBackend.open_document(&document).is_ok());
116 /// ```
117 fn open_document(&mut self, _document: &Document) -> Result<(), LspError> {
118 Ok(())
119 }
120
121 /// Notifies the backend that document content or metadata changed.
122 ///
123 /// The default is a no-op success and performs no version validation.
124 ///
125 /// # Errors
126 ///
127 /// The default implementation never fails. A backend override may return a
128 /// categorized [`LspError`] for transport, protocol, availability, or I/O
129 /// failure.
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use ailloli_ui_editor::{Document, DocumentId, LspBackend, NoopLspBackend};
135 /// use ailloli_ui_text::TextBuffer;
136 /// let document = Document::new(DocumentId(1), TextBuffer::new());
137 /// assert!(NoopLspBackend.change_document(&document).is_ok());
138 /// ```
139 fn change_document(&mut self, _document: &Document) -> Result<(), LspError> {
140 Ok(())
141 }
142
143 /// Notifies the backend that a document closed.
144 ///
145 /// The default is a no-op success.
146 ///
147 /// # Errors
148 ///
149 /// The default implementation never fails. A backend override may return a
150 /// categorized [`LspError`] for transport, protocol, availability, or I/O
151 /// failure.
152 ///
153 /// # Examples
154 ///
155 /// ```
156 /// use ailloli_ui_editor::{Document, DocumentId, LspBackend, NoopLspBackend};
157 /// use ailloli_ui_text::TextBuffer;
158 /// let document = Document::new(DocumentId(1), TextBuffer::new());
159 /// assert!(NoopLspBackend.close_document(&document).is_ok());
160 /// ```
161 fn close_document(&mut self, _document: &Document) -> Result<(), LspError> {
162 Ok(())
163 }
164
165 /// Requests cancellation of an opaque request ID.
166 ///
167 /// The default returns [`LspError::RequestCancelled`] containing the same ID;
168 /// it does not track or signal any real request.
169 ///
170 /// # Errors
171 ///
172 /// The default returns [`LspError::RequestCancelled`] with the supplied ID.
173 /// Backend overrides may additionally report availability, protocol,
174 /// transport, or I/O failures.
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use ailloli_ui_editor::{LspBackend, LspError, LspRequestId, NoopLspBackend};
180 /// assert_eq!(NoopLspBackend.cancel(LspRequestId(7)), Err(LspError::RequestCancelled(LspRequestId(7))));
181 /// ```
182 fn cancel(&mut self, request_id: LspRequestId) -> Result<(), LspError> {
183 Err(LspError::RequestCancelled(request_id))
184 }
185
186 /// Returns semantic symbols for a document.
187 ///
188 /// The default returns `CapabilityUnavailable("document_symbols")`.
189 ///
190 /// # Errors
191 ///
192 /// The default returns [`LspError::CapabilityUnavailable`]. A backend may
193 /// instead return cancellation, transport, protocol, availability, or I/O
194 /// failures while servicing the query.
195 ///
196 /// # Examples
197 ///
198 /// ```
199 /// use ailloli_ui_editor::{Document, DocumentId, LspBackend, LspError, NoopLspBackend};
200 /// use ailloli_ui_text::TextBuffer;
201 /// let document = Document::new(DocumentId(1), TextBuffer::new());
202 /// assert_eq!(NoopLspBackend.document_symbols(&document), Err(LspError::CapabilityUnavailable("document_symbols")));
203 /// ```
204 fn document_symbols(
205 &mut self,
206 _document: &Document,
207 ) -> Result<Vec<SemanticDocumentSymbol>, LspError> {
208 Err(LspError::CapabilityUnavailable("document_symbols"))
209 }
210
211 /// Returns semantic reference edges for a document.
212 ///
213 /// The default returns `CapabilityUnavailable("references")`.
214 ///
215 /// # Errors
216 ///
217 /// The default returns [`LspError::CapabilityUnavailable`]. A backend may
218 /// instead return cancellation, transport, protocol, availability, or I/O
219 /// failures while servicing the query.
220 ///
221 /// # Examples
222 ///
223 /// ```
224 /// use ailloli_ui_editor::{Document, DocumentId, LspBackend, LspError, NoopLspBackend};
225 /// use ailloli_ui_text::TextBuffer;
226 /// let document = Document::new(DocumentId(1), TextBuffer::new());
227 /// assert_eq!(NoopLspBackend.references(&document), Err(LspError::CapabilityUnavailable("references")));
228 /// ```
229 fn references(&mut self, _document: &Document) -> Result<Vec<SemanticReference>, LspError> {
230 Err(LspError::CapabilityUnavailable("references"))
231 }
232
233 /// Returns version-tagged diagnostics for a document.
234 ///
235 /// The default returns `CapabilityUnavailable("diagnostics")`.
236 ///
237 /// # Errors
238 ///
239 /// The default returns [`LspError::CapabilityUnavailable`]. A backend may
240 /// instead return cancellation, transport, protocol, availability, or I/O
241 /// failures while servicing the query.
242 ///
243 /// # Examples
244 ///
245 /// ```
246 /// use ailloli_ui_editor::{Document, DocumentId, LspBackend, LspError, NoopLspBackend};
247 /// use ailloli_ui_text::TextBuffer;
248 /// let document = Document::new(DocumentId(1), TextBuffer::new());
249 /// assert_eq!(NoopLspBackend.diagnostics(&document), Err(LspError::CapabilityUnavailable("diagnostics")));
250 /// ```
251 fn diagnostics(&mut self, _document: &Document) -> Result<Vec<LspDiagnostic>, LspError> {
252 Err(LspError::CapabilityUnavailable("diagnostics"))
253 }
254}
255
256/// Backend whose capabilities are all false and whose methods use trait defaults.
257///
258/// # Examples
259///
260/// ```
261/// use ailloli_ui_editor::{LspBackend, LspCapabilities, NoopLspBackend};
262/// assert_eq!(NoopLspBackend.capabilities(), LspCapabilities::default());
263/// ```
264#[derive(Debug, Default)]
265pub struct NoopLspBackend;
266
267/// Advertises no optional LSP capabilities.
268impl LspBackend for NoopLspBackend {
269 /// Returns [`LspCapabilities::default`].
270 fn capabilities(&self) -> LspCapabilities {
271 LspCapabilities::default()
272 }
273}
274
275/// Language-server/SCIP symbol payload before conversion to local symbol IR.
276///
277/// Ranges are unvalidated UTF-8 byte offsets. `detail` typically contains a
278/// signature, while `None` means the backend supplied no detail.
279///
280/// # Examples
281///
282/// ```
283/// use ailloli_ui_editor::{code::SemanticDocumentSymbol, SymbolKind, SymbolSource};
284/// let symbol = SemanticDocumentSymbol { name: "run".into(), kind: SymbolKind::Function, range: 0..8, selection_range: 3..6, detail: None, source: SymbolSource::Lsp };
285/// assert_eq!(symbol.selection_range, 3..6);
286/// ```
287#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
288pub struct SemanticDocumentSymbol {
289 /// Display name, including an empty name if supplied by the backend.
290 pub name: String,
291 /// Language-neutral symbol category.
292 pub kind: SymbolKind,
293 /// Half-open extent in document UTF-8 bytes.
294 pub range: Range<usize>,
295 /// Half-open name/selection range in document UTF-8 bytes.
296 pub selection_range: Range<usize>,
297 /// Optional backend detail copied into the local signature field.
298 pub detail: Option<String>,
299 /// Claimed semantic source; conversion functions override this field.
300 pub source: SymbolSource,
301}
302
303/// Directed semantic relation between two summary-local symbol IDs.
304///
305/// # Examples
306///
307/// ```
308/// use ailloli_ui_editor::{code::SemanticReference, SymbolEdgeKind, SymbolId, SymbolSource};
309/// let reference = SemanticReference { from: SymbolId(1), to: SymbolId(2), kind: SymbolEdgeKind::Calls, source: SymbolSource::Lsp };
310/// assert_eq!(reference.to, SymbolId(2));
311/// ```
312#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
313pub struct SemanticReference {
314 /// Source symbol ID.
315 pub from: SymbolId,
316 /// Target symbol ID.
317 pub to: SymbolId,
318 /// Directed relation category.
319 pub kind: SymbolEdgeKind,
320 /// Provenance retained in this input model but omitted from [`SymbolEdge`].
321 pub source: SymbolSource,
322}
323
324/// Raw version-tagged LSP diagnostic.
325///
326/// # Examples
327///
328/// ```
329/// use ailloli_ui_editor::{DiagnosticSeverity, DocumentVersion, LspDiagnostic};
330/// let diagnostic = LspDiagnostic { range: 1..3, severity: DiagnosticSeverity::Warning, message: "unused".into(), document_version: DocumentVersion(2) };
331/// assert_eq!(diagnostic.document_version, DocumentVersion(2));
332/// ```
333#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
334pub struct LspDiagnostic {
335 /// Unvalidated half-open UTF-8 byte range.
336 pub range: Range<usize>,
337 /// Diagnostic importance.
338 pub severity: DiagnosticSeverity,
339 /// Exact backend message.
340 pub message: String,
341 /// Document version to which the diagnostic applies.
342 pub document_version: DocumentVersion,
343}
344
345/// Converts semantic symbols into document-local symbols sourced as LSP.
346///
347/// IDs are assigned from one in input order. Both ranges are numerically
348/// clamped to document byte length without UTF-8 boundary repair or ordering;
349/// parent and docs are `None`, and `detail` becomes the signature. Each input's
350/// own `source` field is ignored.
351///
352/// # Examples
353///
354/// ```
355/// use ailloli_ui_editor::{code::{lsp_symbols_to_code_symbols, SemanticDocumentSymbol}, Document, DocumentId, SymbolId, SymbolKind, SymbolSource};
356/// use ailloli_ui_text::TextBuffer;
357/// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn x"));
358/// let input = [SemanticDocumentSymbol { name: "x".into(), kind: SymbolKind::Function, range: 0..99, selection_range: 3..4, detail: Some("fn x".into()), source: SymbolSource::Scip }];
359/// let symbols = lsp_symbols_to_code_symbols(&document, &input);
360/// assert_eq!((symbols[0].id, symbols[0].source, symbols[0].range.clone()), (SymbolId(1), SymbolSource::Lsp, 0..4));
361/// ```
362pub fn lsp_symbols_to_code_symbols(
363 document: &Document,
364 symbols: &[SemanticDocumentSymbol],
365) -> Vec<CodeSymbol> {
366 semantic_symbols_to_code_symbols(document, symbols, SymbolSource::Lsp)
367}
368
369/// Converts semantic symbols into document-local symbols sourced as SCIP.
370///
371/// Conversion otherwise follows [`lsp_symbols_to_code_symbols`]: positional
372/// one-based IDs, numeric length clamping, no parent/docs, and detail-to-signature.
373///
374/// # Examples
375///
376/// ```
377/// use ailloli_ui_editor::{code::{scip_symbols_to_code_symbols, SemanticDocumentSymbol}, Document, DocumentId, SymbolKind, SymbolSource};
378/// use ailloli_ui_text::TextBuffer;
379/// let document = Document::new(DocumentId(1), TextBuffer::from_string("x"));
380/// let input = [SemanticDocumentSymbol { name: "x".into(), kind: SymbolKind::Variable, range: 0..1, selection_range: 0..1, detail: None, source: SymbolSource::Lsp }];
381/// assert_eq!(scip_symbols_to_code_symbols(&document, &input)[0].source, SymbolSource::Scip);
382/// ```
383pub fn scip_symbols_to_code_symbols(
384 document: &Document,
385 symbols: &[SemanticDocumentSymbol],
386) -> Vec<CodeSymbol> {
387 semantic_symbols_to_code_symbols(document, symbols, SymbolSource::Scip)
388}
389
390/// Copies semantic references into source-agnostic symbol edges.
391///
392/// Order and duplicates are preserved; each input's `source` field is dropped.
393///
394/// # Examples
395///
396/// ```
397/// use ailloli_ui_editor::{code::{semantic_references_to_edges, SemanticReference}, SymbolEdgeKind, SymbolId, SymbolSource};
398/// let references = [SemanticReference { from: SymbolId(1), to: SymbolId(2), kind: SymbolEdgeKind::References, source: SymbolSource::Lsp }];
399/// let edges = semantic_references_to_edges(&references);
400/// assert_eq!((edges[0].from, edges[0].to), (SymbolId(1), SymbolId(2)));
401/// ```
402pub fn semantic_references_to_edges(references: &[SemanticReference]) -> Vec<SymbolEdge> {
403 references
404 .iter()
405 .map(|reference| SymbolEdge {
406 from: reference.from,
407 to: reference.to,
408 kind: reference.kind,
409 })
410 .collect()
411}
412
413/// Converts current-version LSP diagnostics into local diagnostics.
414///
415/// Stale versions are discarded. Endpoints clamp numerically to document byte
416/// length without UTF-8 boundary repair; empty or reversed results are removed.
417/// Remaining inputs preserve order and become [`crate::DiagnosticSource::Lsp`].
418///
419/// # Examples
420///
421/// ```
422/// use ailloli_ui_editor::{code::lsp_diagnostics_to_diagnostics, DiagnosticSeverity, Document, DocumentId, DocumentVersion, LspDiagnostic};
423/// use ailloli_ui_text::TextBuffer;
424/// let document = Document::new(DocumentId(1), TextBuffer::from_string("abc"));
425/// let diagnostics = [
426/// LspDiagnostic { range: 1..99, severity: DiagnosticSeverity::Error, message: "bad".into(), document_version: DocumentVersion(0) },
427/// LspDiagnostic { range: 0..1, severity: DiagnosticSeverity::Hint, message: "stale".into(), document_version: DocumentVersion(1) },
428/// ];
429/// let mapped = lsp_diagnostics_to_diagnostics(&document, &diagnostics);
430/// assert_eq!(mapped.len(), 1);
431/// assert_eq!(mapped[0].range, 1..3);
432/// ```
433pub fn lsp_diagnostics_to_diagnostics(
434 document: &Document,
435 diagnostics: &[LspDiagnostic],
436) -> Vec<Diagnostic> {
437 diagnostics
438 .iter()
439 .filter(|diagnostic| diagnostic.document_version == document.version)
440 .map(|diagnostic| {
441 Diagnostic::lsp(
442 clamp_range(diagnostic.range.clone(), document.buffer.len_bytes()),
443 diagnostic.severity,
444 diagnostic.message.clone(),
445 diagnostic.document_version,
446 )
447 })
448 .filter(|diagnostic| diagnostic.range.start < diagnostic.range.end)
449 .collect()
450}
451
452/// Collects one synchronous enrichment snapshot from advertised capabilities.
453///
454/// Queries run in symbol, reference, then diagnostic order and stop at the first
455/// error. Unadvertised query methods are not called and yield empty vectors.
456/// Diagnostics are current-version filtered and clamped; symbols/references are
457/// returned in backend form for later mapping. This function owns no timeout,
458/// queue, cancellation, or concurrency policy.
459///
460/// # Errors
461///
462/// Returns the first error produced by an advertised backend query.
463///
464/// # Examples
465///
466/// ```
467/// use ailloli_ui_editor::{code::collect_lsp_enrichment, Document, DocumentId, NoopLspBackend};
468/// use ailloli_ui_text::TextBuffer;
469/// let document = Document::new(DocumentId(1), TextBuffer::new());
470/// let enrichment = collect_lsp_enrichment(&mut NoopLspBackend, &document).unwrap();
471/// assert!(enrichment.symbols.is_empty() && enrichment.diagnostics.is_empty());
472/// ```
473pub fn collect_lsp_enrichment<B: LspBackend>(
474 backend: &mut B,
475 document: &Document,
476) -> Result<LspEnrichment, LspError> {
477 let capabilities = backend.capabilities();
478 let symbols = if capabilities.document_symbols {
479 backend.document_symbols(document)?
480 } else {
481 Vec::new()
482 };
483 let references = if capabilities.references {
484 backend.references(document)?
485 } else {
486 Vec::new()
487 };
488 let diagnostics = if capabilities.diagnostics {
489 lsp_diagnostics_to_diagnostics(document, &backend.diagnostics(document)?)
490 } else {
491 Vec::new()
492 };
493
494 Ok(LspEnrichment {
495 document_version: document.version,
496 capabilities,
497 symbols,
498 references,
499 diagnostics,
500 })
501}
502
503/// Complete enrichment snapshot tied to one document version.
504///
505/// # Examples
506///
507/// ```
508/// use ailloli_ui_editor::{DocumentVersion, LspCapabilities, LspEnrichment};
509/// let enrichment = LspEnrichment { document_version: DocumentVersion(3), capabilities: LspCapabilities::default(), symbols: vec![], references: vec![], diagnostics: vec![] };
510/// assert_eq!(enrichment.document_version, DocumentVersion(3));
511/// ```
512#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
513pub struct LspEnrichment {
514 /// Document version captured before backend queries.
515 pub document_version: DocumentVersion,
516 /// Capability snapshot used to decide which queries ran.
517 pub capabilities: LspCapabilities,
518 /// Raw semantic symbols from the backend.
519 pub symbols: Vec<SemanticDocumentSymbol>,
520 /// Raw semantic references from the backend.
521 pub references: Vec<SemanticReference>,
522 /// Current-version mapped LSP diagnostics.
523 pub diagnostics: Vec<Diagnostic>,
524}
525
526/// JSON-deserializable SCIP-like project index.
527///
528/// This is the crate's compact interchange schema, not a protobuf SCIP decoder.
529/// Document order determines one-based IDs in project summaries.
530///
531/// # Examples
532///
533/// ```
534/// use ailloli_ui_editor::{ScipProjectIndex, ScipProjectMetadata};
535/// let index = ScipProjectIndex { metadata: ScipProjectMetadata { project_root: "/repo".into(), tool_info: "tool 1".into() }, documents: vec![] };
536/// assert!(index.documents.is_empty());
537/// ```
538#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
539pub struct ScipProjectIndex {
540 /// Project-wide origin/tool metadata.
541 pub metadata: ScipProjectMetadata,
542 /// Indexed files in deterministic caller-provided order.
543 pub documents: Vec<ScipDocumentIndex>,
544}
545
546/// Project provenance stored with SCIP import data.
547///
548/// Both strings are opaque and may be empty; no path canonicalization or tool
549/// version parsing occurs.
550///
551/// # Examples
552///
553/// ```
554/// use ailloli_ui_editor::ScipProjectMetadata;
555/// let metadata = ScipProjectMetadata { project_root: "workspace".into(), tool_info: "scip-tool".into() };
556/// assert_eq!(metadata.project_root, "workspace");
557/// ```
558#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
559pub struct ScipProjectMetadata {
560 /// Opaque project-root string.
561 pub project_root: String,
562 /// Opaque producer name/version string.
563 pub tool_info: String,
564}
565
566/// One imported SCIP document and its local semantic records.
567///
568/// Ranges throughout are unvalidated UTF-8 byte offsets. Symbol relations are
569/// resolved only against this document; occurrences additionally drive
570/// cross-document navigation.
571///
572/// # Examples
573///
574/// ```
575/// use ailloli_ui_editor::{DocumentVersion, EditorLanguage, ScipDocumentIndex};
576/// let document = ScipDocumentIndex { path: "src/lib.rs".into(), language: EditorLanguage::Rust, version: DocumentVersion(1), symbols: vec![], occurrences: vec![], relations: vec![] };
577/// assert_eq!(document.language, EditorLanguage::Rust);
578/// ```
579#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
580pub struct ScipDocumentIndex {
581 /// Opaque project-relative or absolute path string.
582 pub path: String,
583 /// Language attached by the index producer.
584 pub language: EditorLanguage,
585 /// Producer-supplied document version.
586 pub version: DocumentVersion,
587 /// Definitions/descriptors in positional-ID order.
588 pub symbols: Vec<ScipSymbol>,
589 /// Definition/reference occurrences used for navigation.
590 pub occurrences: Vec<ScipOccurrence>,
591 /// Local symbol-name relations used for graph edges.
592 pub relations: Vec<ScipRelation>,
593}
594
595/// Imported SCIP symbol descriptor.
596///
597/// # Examples
598///
599/// ```
600/// use ailloli_ui_editor::{ScipSymbol, SymbolKind};
601/// let symbol = ScipSymbol { symbol: "pkg/foo#".into(), name: "foo".into(), kind: SymbolKind::Function, range: 0..8, selection_range: 3..6, signature: None, docs: None };
602/// assert_eq!(symbol.name, "foo");
603/// ```
604#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
605pub struct ScipSymbol {
606 /// Canonical SCIP symbol string used to resolve relations/occurrences.
607 pub symbol: String,
608 /// Human-readable symbol name.
609 pub name: String,
610 /// Language-neutral symbol category.
611 pub kind: SymbolKind,
612 /// Unvalidated half-open full extent in document UTF-8 bytes.
613 pub range: Range<usize>,
614 /// Unvalidated half-open name range in document UTF-8 bytes.
615 pub selection_range: Range<usize>,
616 /// Optional exact signature; `None` differs from an empty signature.
617 pub signature: Option<String>,
618 /// Optional exact documentation; `None` differs from empty documentation.
619 pub docs: Option<String>,
620}
621
622/// Whether a SCIP occurrence defines or references its symbol.
623///
624/// # Examples
625///
626/// ```
627/// use ailloli_ui_editor::ScipOccurrenceRole;
628/// assert_ne!(ScipOccurrenceRole::Definition, ScipOccurrenceRole::Reference);
629/// ```
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
631pub enum ScipOccurrenceRole {
632 /// Target location for navigation.
633 Definition,
634 /// Source location linked to the first matching definition.
635 Reference,
636}
637
638/// One definition or reference occurrence of a canonical SCIP symbol.
639///
640/// # Examples
641///
642/// ```
643/// use ailloli_ui_editor::{ScipOccurrence, ScipOccurrenceRole};
644/// let occurrence = ScipOccurrence { symbol: "pkg/foo#".into(), range: 4..7, role: ScipOccurrenceRole::Reference };
645/// assert_eq!(occurrence.range, 4..7);
646/// ```
647#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
648pub struct ScipOccurrence {
649 /// Canonical SCIP symbol string.
650 pub symbol: String,
651 /// Unvalidated half-open occurrence range in UTF-8 bytes.
652 pub range: Range<usize>,
653 /// Definition or reference role.
654 pub role: ScipOccurrenceRole,
655}
656
657/// Local relation between two canonical SCIP symbol strings.
658///
659/// # Examples
660///
661/// ```
662/// use ailloli_ui_editor::{ScipRelation, SymbolEdgeKind};
663/// let relation = ScipRelation { from_symbol: "a".into(), to_symbol: "b".into(), kind: SymbolEdgeKind::Calls };
664/// assert_eq!(relation.kind, SymbolEdgeKind::Calls);
665/// ```
666#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
667pub struct ScipRelation {
668 /// Canonical source symbol string.
669 pub from_symbol: String,
670 /// Canonical target symbol string.
671 pub to_symbol: String,
672 /// Directed relation category.
673 pub kind: SymbolEdgeKind,
674}
675
676/// Converted project summaries plus cross-document navigation links.
677///
678/// # Examples
679///
680/// ```
681/// use ailloli_ui_editor::{ScipProjectMetadata, ScipProjectSummary};
682/// let summary = ScipProjectSummary { metadata: ScipProjectMetadata { project_root: String::new(), tool_info: String::new() }, documents: vec![], navigation: vec![] };
683/// assert!(summary.documents.is_empty() && summary.navigation.is_empty());
684/// ```
685#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
686pub struct ScipProjectSummary {
687 /// Metadata copied from the imported project.
688 pub metadata: ScipProjectMetadata,
689 /// Per-document local symbol summaries.
690 pub documents: Vec<CodeFileSummary>,
691 /// Sorted cross-document reference-to-definition links.
692 pub navigation: Vec<ScipNavigationLink>,
693}
694
695/// Cross-document navigation from one reference to a definition.
696///
697/// # Examples
698///
699/// ```
700/// use ailloli_ui_editor::{ScipNavigationLink, SymbolEdgeKind};
701/// let link = ScipNavigationLink { from_path: "a.rs".into(), from_range: 1..2, to_path: "b.rs".into(), to_symbol: "pkg/b#".into(), kind: SymbolEdgeKind::References };
702/// assert_eq!(link.to_path, "b.rs");
703/// ```
704#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
705pub struct ScipNavigationLink {
706 /// Path containing the reference.
707 pub from_path: String,
708 /// Unvalidated half-open reference range.
709 pub from_range: Range<usize>,
710 /// Path containing the chosen definition.
711 pub to_path: String,
712 /// Canonical SCIP target symbol.
713 pub to_symbol: String,
714 /// Link category, currently always [`SymbolEdgeKind::References`].
715 pub kind: SymbolEdgeKind,
716}
717
718/// Failure while importing the compact SCIP JSON schema.
719///
720/// # Examples
721///
722/// ```
723/// use ailloli_ui_editor::{code::import_scip_json_str, ScipImportError};
724/// assert!(matches!(import_scip_json_str("not json"), Err(ScipImportError::Json(_))));
725/// ```
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub enum ScipImportError {
728 /// Owned `serde_json` parse/shape error text.
729 Json(String),
730}
731
732/// Deserializes the crate's compact SCIP JSON schema.
733///
734/// Unknown fields are accepted by Serde defaults, while every declared field is
735/// required unless its type's deserializer says otherwise. No semantic range,
736/// path, symbol, or relation validation is performed.
737///
738/// # Errors
739///
740/// Returns [`ScipImportError::Json`] with Serde's owned error text for invalid
741/// JSON or an incompatible shape.
742///
743/// # Examples
744///
745/// ```
746/// use ailloli_ui_editor::code::import_scip_json_str;
747/// let index = import_scip_json_str(r#"{"metadata":{"project_root":"/repo","tool_info":"tool"},"documents":[]}"#).unwrap();
748/// assert_eq!(index.metadata.project_root, "/repo");
749/// ```
750pub fn import_scip_json_str(json: &str) -> Result<ScipProjectIndex, ScipImportError> {
751 serde_json::from_str(json).map_err(|err| ScipImportError::Json(err.to_string()))
752}
753
754/// Converts every imported document and builds cross-file navigation.
755///
756/// Documents receive one-based [`DocumentId`] values in input order. Metadata
757/// is cloned, local symbol summaries are converted independently, and reference
758/// links are sorted deterministically.
759///
760/// # Examples
761///
762/// ```
763/// use ailloli_ui_editor::{code::scip_project_to_summary, ScipProjectIndex, ScipProjectMetadata};
764/// let index = ScipProjectIndex { metadata: ScipProjectMetadata { project_root: "repo".into(), tool_info: "tool".into() }, documents: vec![] };
765/// let summary = scip_project_to_summary(&index);
766/// assert_eq!(summary.metadata.project_root, "repo");
767/// assert!(summary.documents.is_empty());
768/// ```
769pub fn scip_project_to_summary(index: &ScipProjectIndex) -> ScipProjectSummary {
770 let documents: Vec<_> = index
771 .documents
772 .iter()
773 .enumerate()
774 .map(|(idx, document)| scip_document_to_summary(DocumentId(idx as u64 + 1), document))
775 .collect();
776 let navigation = scip_navigation_links(index);
777 ScipProjectSummary {
778 metadata: index.metadata.clone(),
779 documents,
780 navigation,
781 }
782}
783
784/// Converts one SCIP document into the local symbol/edge representation.
785///
786/// Symbols receive positional one-based IDs. Ranges, signatures, and docs are
787/// copied without clamping; parents are always `None` and source is SCIP.
788/// Relations whose canonical endpoints cannot both be resolved are dropped;
789/// remaining edges are sorted and deduplicated.
790///
791/// # Examples
792///
793/// ```
794/// use ailloli_ui_editor::{code::scip_document_to_summary, DocumentId, DocumentVersion, EditorLanguage, ScipDocumentIndex, ScipSymbol, SymbolId, SymbolKind, SymbolSource};
795/// let document = ScipDocumentIndex { path: "lib.rs".into(), language: EditorLanguage::Rust, version: DocumentVersion(2), symbols: vec![ScipSymbol { symbol: "foo".into(), name: "foo".into(), kind: SymbolKind::Function, range: 0..3, selection_range: 0..3, signature: None, docs: None }], occurrences: vec![], relations: vec![] };
796/// let summary = scip_document_to_summary(DocumentId(9), &document);
797/// assert_eq!((summary.symbols[0].id, summary.symbols[0].source), (SymbolId(1), SymbolSource::Scip));
798/// assert_eq!(summary.path.unwrap().to_str(), Some("lib.rs"));
799/// ```
800pub fn scip_document_to_summary(
801 document_id: DocumentId,
802 document: &ScipDocumentIndex,
803) -> CodeFileSummary {
804 let symbols: Vec<_> = document
805 .symbols
806 .iter()
807 .enumerate()
808 .map(|(idx, symbol)| CodeSymbol {
809 id: SymbolId(idx as u64 + 1),
810 name: symbol.name.clone(),
811 kind: symbol.kind,
812 language: document.language,
813 range: symbol.range.clone(),
814 selection_range: symbol.selection_range.clone(),
815 parent: None,
816 signature: symbol.signature.clone(),
817 docs: symbol.docs.clone(),
818 source: SymbolSource::Scip,
819 })
820 .collect();
821 let edges = scip_edges(document, &symbols);
822 CodeFileSummary {
823 document_id,
824 path: Some(document.path.clone().into()),
825 language: document.language,
826 version: document.version,
827 symbols,
828 edges,
829 }
830}
831
832/// Merges symbol and edge payloads under caller-supplied document metadata.
833///
834/// Symbols deduplicate by name, kind, and selection range. For duplicates the
835/// strongest source wins: Tree-sitter, LSP, SCIP, ctags, then lexical. Results
836/// sort by selection start/source/name and receive fresh positional one-based
837/// IDs. Edges from all summaries sort by kind/from/to and deduplicate exactly;
838/// their IDs are not remapped to the newly assigned symbol IDs.
839///
840/// # Examples
841///
842/// ```
843/// use ailloli_ui_editor::{code::merge_code_file_summaries, CodeFileSummary, CodeSymbol, DocumentId, DocumentVersion, EditorLanguage, SymbolId, SymbolKind, SymbolSource};
844/// fn summary(source: SymbolSource) -> CodeFileSummary {
845/// CodeFileSummary { document_id: DocumentId(1), path: None, language: EditorLanguage::Rust, version: DocumentVersion(0), symbols: vec![CodeSymbol { id: SymbolId(99), name: "f".into(), kind: SymbolKind::Function, language: EditorLanguage::Rust, range: 0..1, selection_range: 0..1, parent: None, signature: None, docs: None, source }], edges: vec![] }
846/// }
847/// let merged = merge_code_file_summaries(DocumentId(7), None, EditorLanguage::Rust, DocumentVersion(3), &[summary(SymbolSource::Lexical), summary(SymbolSource::Lsp)]);
848/// assert_eq!(merged.symbols.len(), 1);
849/// assert_eq!((merged.symbols[0].id, merged.symbols[0].source), (SymbolId(1), SymbolSource::Lsp));
850/// ```
851pub fn merge_code_file_summaries(
852 document_id: DocumentId,
853 path: Option<std::path::PathBuf>,
854 language: EditorLanguage,
855 version: DocumentVersion,
856 summaries: &[CodeFileSummary],
857) -> CodeFileSummary {
858 let mut symbols: Vec<CodeSymbol> = Vec::new();
859 for summary in summaries {
860 for symbol in &summary.symbols {
861 if let Some(existing) = symbols.iter_mut().find(|existing| {
862 existing.name == symbol.name
863 && existing.kind == symbol.kind
864 && existing.selection_range == symbol.selection_range
865 }) {
866 if symbol_source_priority(symbol.source) < symbol_source_priority(existing.source) {
867 *existing = symbol.clone();
868 }
869 } else {
870 symbols.push(symbol.clone());
871 }
872 }
873 }
874 symbols.sort_by_key(|symbol| {
875 (
876 symbol.selection_range.start,
877 symbol_source_priority(symbol.source),
878 symbol.name.clone(),
879 )
880 });
881 for (idx, symbol) in symbols.iter_mut().enumerate() {
882 symbol.id = SymbolId(idx as u64 + 1);
883 }
884
885 let mut edges = summaries
886 .iter()
887 .flat_map(|summary| summary.edges.iter().cloned())
888 .collect::<Vec<_>>();
889 edges.sort_by_key(|edge| (symbol_edge_kind_rank(edge.kind), edge.from.0, edge.to.0));
890 edges.dedup_by_key(|edge| (symbol_edge_kind_rank(edge.kind), edge.from.0, edge.to.0));
891
892 CodeFileSummary {
893 document_id,
894 path,
895 language,
896 version,
897 symbols,
898 edges,
899 }
900}
901
902/// Converts semantic symbols while forcing a specified provenance.
903fn semantic_symbols_to_code_symbols(
904 document: &Document,
905 symbols: &[SemanticDocumentSymbol],
906 source: SymbolSource,
907) -> Vec<CodeSymbol> {
908 symbols
909 .iter()
910 .enumerate()
911 .map(|(idx, symbol)| {
912 let range = clamp_range(symbol.range.clone(), document.buffer.len_bytes());
913 let selection_range =
914 clamp_range(symbol.selection_range.clone(), document.buffer.len_bytes());
915 CodeSymbol {
916 id: SymbolId(idx as u64 + 1),
917 name: symbol.name.clone(),
918 kind: symbol.kind,
919 language: document.language,
920 range,
921 selection_range,
922 parent: None,
923 signature: symbol.detail.clone(),
924 docs: None,
925 source,
926 }
927 })
928 .collect()
929}
930
931/// Numerically clamps both range endpoints to a byte length.
932fn clamp_range(range: Range<usize>, len: usize) -> Range<usize> {
933 range.start.min(len)..range.end.min(len)
934}
935
936/// Resolves, sorts, and deduplicates one document's canonical-name relations.
937fn scip_edges(document: &ScipDocumentIndex, symbols: &[CodeSymbol]) -> Vec<SymbolEdge> {
938 let mut edges = Vec::new();
939 for relation in &document.relations {
940 let Some(from) = symbol_id_for_scip_name(symbols, document, &relation.from_symbol) else {
941 continue;
942 };
943 let Some(to) = symbol_id_for_scip_name(symbols, document, &relation.to_symbol) else {
944 continue;
945 };
946 edges.push(SymbolEdge {
947 from,
948 to,
949 kind: relation.kind,
950 });
951 }
952 edges.sort_by_key(|edge| (symbol_edge_kind_rank(edge.kind), edge.from.0, edge.to.0));
953 edges.dedup_by_key(|edge| (symbol_edge_kind_rank(edge.kind), edge.from.0, edge.to.0));
954 edges
955}
956
957/// Resolves the first imported SCIP symbol with a canonical name to positional ID.
958fn symbol_id_for_scip_name(
959 symbols: &[CodeSymbol],
960 document: &ScipDocumentIndex,
961 scip_symbol: &str,
962) -> Option<SymbolId> {
963 document
964 .symbols
965 .iter()
966 .position(|symbol| symbol.symbol == scip_symbol)
967 .and_then(|idx| symbols.get(idx))
968 .map(|symbol| symbol.id)
969}
970
971/// Builds sorted cross-document links to the first matching definition.
972fn scip_navigation_links(index: &ScipProjectIndex) -> Vec<ScipNavigationLink> {
973 let definitions: Vec<_> = index
974 .documents
975 .iter()
976 .flat_map(|document| {
977 document
978 .occurrences
979 .iter()
980 .filter(|occurrence| occurrence.role == ScipOccurrenceRole::Definition)
981 .map(move |occurrence| (document.path.clone(), occurrence.clone()))
982 })
983 .collect();
984 let mut links = Vec::new();
985 for document in &index.documents {
986 for occurrence in &document.occurrences {
987 if occurrence.role != ScipOccurrenceRole::Reference {
988 continue;
989 }
990 if let Some((target_path, _)) = definitions
991 .iter()
992 .find(|(_, definition)| definition.symbol == occurrence.symbol)
993 {
994 links.push(ScipNavigationLink {
995 from_path: document.path.clone(),
996 from_range: occurrence.range.clone(),
997 to_path: target_path.clone(),
998 to_symbol: occurrence.symbol.clone(),
999 kind: SymbolEdgeKind::References,
1000 });
1001 }
1002 }
1003 }
1004 links.sort_by(|a, b| {
1005 (
1006 a.from_path.as_str(),
1007 a.from_range.start,
1008 a.to_path.as_str(),
1009 a.to_symbol.as_str(),
1010 )
1011 .cmp(&(
1012 b.from_path.as_str(),
1013 b.from_range.start,
1014 b.to_path.as_str(),
1015 b.to_symbol.as_str(),
1016 ))
1017 });
1018 links
1019}
1020
1021/// Returns lower-is-stronger provenance priority for symbol merging.
1022fn symbol_source_priority(source: SymbolSource) -> u8 {
1023 match source {
1024 SymbolSource::TreeSitter => 0,
1025 SymbolSource::Lsp => 1,
1026 SymbolSource::Scip => 2,
1027 SymbolSource::Ctags => 3,
1028 SymbolSource::Lexical => 4,
1029 }
1030}
1031
1032/// Returns deterministic sort rank for symbol edge categories.
1033fn symbol_edge_kind_rank(kind: SymbolEdgeKind) -> u8 {
1034 match kind {
1035 SymbolEdgeKind::Contains => 0,
1036 SymbolEdgeKind::Imports => 1,
1037 SymbolEdgeKind::Calls => 2,
1038 SymbolEdgeKind::References => 3,
1039 SymbolEdgeKind::Extends => 4,
1040 SymbolEdgeKind::Implements => 5,
1041 }
1042}