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}