ailloli_ui_editor/code/
symbols.rs

1//! Local lexical, Tree-sitter, and optional universal-ctags symbol indexing.
2
3use std::ops::Range;
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use crate::code::{Document, DocumentId, DocumentVersion, EditorLanguage};
9
10/// Stable symbol ID inside one indexed summary.
11///
12/// Zero is reserved by built-in indexers as the synthetic document root; real
13/// symbols receive one-based IDs. The type itself accepts every `u64`.
14///
15/// # Examples
16///
17/// ```
18/// use ailloli_ui_editor::SymbolId;
19/// assert_eq!(SymbolId(1).0, 1);
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
22pub struct SymbolId(pub u64);
23
24/// Language-neutral symbol record for one code document.
25///
26/// Ranges are half-open UTF-8 byte offsets. Built-in indexers keep them within
27/// the document, but this public value type performs no validation.
28///
29/// # Examples
30///
31/// ```
32/// use ailloli_ui_editor::{CodeSymbol, EditorLanguage, SymbolId, SymbolKind, SymbolSource};
33/// let symbol = CodeSymbol { id: SymbolId(1), name: "run".into(), kind: SymbolKind::Function, language: EditorLanguage::Rust, range: 0..10, selection_range: 3..6, parent: None, signature: Some("fn run()".into()), docs: None, source: SymbolSource::Lexical };
34/// assert_eq!(symbol.selection_range, 3..6);
35/// ```
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub struct CodeSymbol {
38    /// Summary-local identifier; zero conventionally denotes the document root.
39    pub id: SymbolId,
40    /// Human-readable symbol name.
41    pub name: String,
42    /// Language-neutral category.
43    pub kind: SymbolKind,
44    /// Language inherited from the indexed document.
45    pub language: EditorLanguage,
46    /// Half-open full declaration extent in UTF-8 bytes.
47    pub range: Range<usize>,
48    /// Half-open name/selection extent in UTF-8 bytes.
49    pub selection_range: Range<usize>,
50    /// Optional containing symbol ID; `None` means document root.
51    pub parent: Option<SymbolId>,
52    /// Optional compact declaration signature.
53    pub signature: Option<String>,
54    /// Optional extracted documentation text.
55    pub docs: Option<String>,
56    /// Indexer/enrichment provenance.
57    pub source: SymbolSource,
58}
59
60/// Language-neutral symbol categories serialized by exact variant name.
61///
62/// # Examples
63///
64/// ```
65/// use ailloli_ui_editor::SymbolKind;
66/// assert_ne!(SymbolKind::Method, SymbolKind::Function);
67/// assert_eq!(serde_json::to_string(&SymbolKind::TypeAlias).unwrap(), "\"TypeAlias\"");
68/// ```
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
70pub enum SymbolKind {
71    /// Language module.
72    Module,
73    /// Namespace or ctags module-like scope.
74    Namespace,
75    /// Generic type/class not represented more specifically.
76    Type,
77    /// Implementation block.
78    Impl,
79    /// Structure declaration.
80    Struct,
81    /// Enumeration declaration.
82    Enum,
83    /// Enumeration variant.
84    EnumVariant,
85    /// Trait declaration.
86    Trait,
87    /// Interface declaration.
88    Interface,
89    /// Free function.
90    Function,
91    /// Function contained by an impl or trait.
92    Method,
93    /// Constructor.
94    Constructor,
95    /// Structure/class field.
96    Field,
97    /// Variable.
98    Variable,
99    /// Constant or static item.
100    Constant,
101    /// Type alias.
102    TypeAlias,
103    /// Macro definition.
104    Macro,
105    /// Import/use declaration.
106    Import,
107    /// Unrecognized producer category.
108    Unknown,
109}
110
111/// Provenance of a symbol record.
112///
113/// Merge preference is Tree-sitter, LSP, SCIP, ctags, then lexical.
114///
115/// # Examples
116///
117/// ```
118/// use ailloli_ui_editor::SymbolSource;
119/// assert_ne!(SymbolSource::TreeSitter, SymbolSource::Lexical);
120/// ```
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
122pub enum SymbolSource {
123    /// Parsed from a Tree-sitter syntax tree.
124    TreeSitter,
125    /// Detected by the lightweight line-oriented fallback.
126    Lexical,
127    /// Imported from universal ctags JSON Lines.
128    Ctags,
129    /// Enriched by a language server.
130    Lsp,
131    /// Imported from SCIP JSON.
132    Scip,
133}
134
135/// Complete symbol graph for one document version.
136///
137/// # Examples
138///
139/// ```
140/// use ailloli_ui_editor::{CodeFileSummary, DocumentId, DocumentVersion, EditorLanguage};
141/// let summary = CodeFileSummary { document_id: DocumentId(1), path: None, language: EditorLanguage::Rust, version: DocumentVersion(0), symbols: vec![], edges: vec![] };
142/// assert!(summary.symbols.is_empty() && summary.edges.is_empty());
143/// ```
144#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
145pub struct CodeFileSummary {
146    /// Document identity copied from the indexed document or supplied by caller.
147    pub document_id: DocumentId,
148    /// Optional document path.
149    pub path: Option<PathBuf>,
150    /// Document language.
151    pub language: EditorLanguage,
152    /// Document version represented by the graph.
153    pub version: DocumentVersion,
154    /// Symbols in stable indexer-defined order.
155    pub symbols: Vec<CodeSymbol>,
156    /// Directed, normally normalized graph edges.
157    pub edges: Vec<SymbolEdge>,
158}
159
160/// Serializes symbol summaries for storage and inspection.
161impl CodeFileSummary {
162    /// Returns pretty-printed JSON containing every summary field.
163    ///
164    /// # Errors
165    ///
166    /// Propagates a Serde JSON serialization error. Current fields are ordinary
167    /// serializable values, so errors are not expected in normal use.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use ailloli_ui_editor::{CodeFileSummary, DocumentId, DocumentVersion, EditorLanguage};
173    /// let summary = CodeFileSummary { document_id: DocumentId(1), path: None, language: EditorLanguage::Rust, version: DocumentVersion(0), symbols: vec![], edges: vec![] };
174    /// let json = summary.to_json_pretty().unwrap();
175    /// assert!(json.contains("\"symbols\": []"));
176    /// ```
177    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
178        serde_json::to_string_pretty(self)
179    }
180}
181
182/// Directed relation between two summary-local symbol IDs.
183///
184/// ID zero conventionally denotes the document root for containment/import
185/// edges, but the value type does not enforce endpoint existence.
186///
187/// # Examples
188///
189/// ```
190/// use ailloli_ui_editor::{SymbolEdge, SymbolEdgeKind, SymbolId};
191/// let edge = SymbolEdge { from: SymbolId(0), to: SymbolId(1), kind: SymbolEdgeKind::Contains };
192/// assert_eq!(edge.to, SymbolId(1));
193/// ```
194#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
195pub struct SymbolEdge {
196    /// Source symbol ID or synthetic root zero.
197    pub from: SymbolId,
198    /// Target symbol ID.
199    pub to: SymbolId,
200    /// Directed relation category.
201    pub kind: SymbolEdgeKind,
202}
203
204/// Language-neutral directed edge categories.
205///
206/// # Examples
207///
208/// ```
209/// use ailloli_ui_editor::SymbolEdgeKind;
210/// assert_ne!(SymbolEdgeKind::Calls, SymbolEdgeKind::References);
211/// ```
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
213pub enum SymbolEdgeKind {
214    /// Parent/root contains a declaration.
215    Contains,
216    /// Caller invokes a function, method, or macro.
217    Calls,
218    /// Root/import scope imports a declaration.
219    Imports,
220    /// Source occurrence references a target.
221    References,
222    /// Source type extends a target.
223    Extends,
224    /// Source type implements a target.
225    Implements,
226}
227
228/// Synchronous interface for indexing one document into a complete summary.
229///
230/// Implementations decide error handling; built-in ctags' trait implementation
231/// is intentionally infallible and returns an empty summary on runner failures.
232///
233/// # Examples
234///
235/// ```
236/// use ailloli_ui_editor::{Document, DocumentId, EditorLanguage, LexicalRustSymbolIndexer, SymbolIndexer};
237/// use ailloli_ui_text::TextBuffer;
238/// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn run() {}")) .with_language(EditorLanguage::Rust);
239/// assert_eq!(LexicalRustSymbolIndexer.index_document(&document).symbols[0].name, "run");
240/// ```
241pub trait SymbolIndexer {
242    /// Indexes the document snapshot synchronously.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use ailloli_ui_editor::{Document, DocumentId, EditorLanguage, LexicalRustSymbolIndexer, SymbolIndexer};
248    /// use ailloli_ui_text::TextBuffer;
249    /// let document = Document::new(DocumentId(1), TextBuffer::from_string("struct App;")) .with_language(EditorLanguage::Rust);
250    /// let summary = LexicalRustSymbolIndexer.index_document(&document);
251    /// assert_eq!(summary.symbols.len(), 1);
252    /// ```
253    fn index_document(&mut self, document: &Document) -> CodeFileSummary;
254}
255
256/// Deterministic line-oriented Rust fallback indexer.
257///
258/// It performs no parsing or I/O and recognizes only trimmed lines beginning
259/// exactly with `use `, `fn `, `struct `, `enum `, or `trait `.
260///
261/// # Examples
262///
263/// ```
264/// use ailloli_ui_editor::{Document, DocumentId, EditorLanguage, LexicalRustSymbolIndexer, SymbolIndexer};
265/// use ailloli_ui_text::TextBuffer;
266/// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn run() {}")) .with_language(EditorLanguage::Rust);
267/// assert_eq!(LexicalRustSymbolIndexer.index_document(&document).symbols.len(), 1);
268/// ```
269#[derive(Debug, Default)]
270pub struct LexicalRustSymbolIndexer;
271
272/// Delegates to the lexical Rust summarizer.
273impl SymbolIndexer for LexicalRustSymbolIndexer {
274    /// Returns [`summarize_rust_lexical`] without failure.
275    fn index_document(&mut self, document: &Document) -> CodeFileSummary {
276        summarize_rust_lexical(document)
277    }
278}
279
280/// Symbol indexer backed by fixture JSON Lines or a ctags subprocess.
281///
282/// The [`SymbolIndexer`] implementation suppresses every [`CtagsError`] into an
283/// empty metadata-preserving summary. Use [`Self::try_index_document`] when the
284/// caller needs diagnostics.
285///
286/// # Examples
287///
288/// ```
289/// use ailloli_ui_editor::{CtagsSymbolIndexer, Document, DocumentId, EditorLanguage, SymbolIndexer};
290/// use ailloli_ui_text::TextBuffer;
291/// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn run() {}")) .with_language(EditorLanguage::Rust);
292/// let mut indexer = CtagsSymbolIndexer::from_json_lines(r#"{"name":"run","kind":"function","line":1}"#);
293/// assert_eq!(indexer.index_document(&document).symbols[0].name, "run");
294/// ```
295#[derive(Debug, Clone)]
296pub struct CtagsSymbolIndexer {
297    /// In-memory JSON Lines or external-runner source.
298    source: CtagsSymbolSource,
299}
300
301/// Input source used by a ctags indexer.
302#[derive(Debug, Clone)]
303enum CtagsSymbolSource {
304    /// Exact JSON Lines fixture/content parsed without I/O.
305    JsonLines(String),
306    /// External subprocess configuration.
307    Runner(CtagsRunnerConfig),
308}
309
310/// Selects the default external ctags runner configuration.
311impl Default for CtagsSymbolIndexer {
312    /// Creates a runner-backed indexer using [`CtagsRunnerConfig::default`].
313    fn default() -> Self {
314        Self {
315            source: CtagsSymbolSource::Runner(CtagsRunnerConfig::default()),
316        }
317    }
318}
319
320/// Constructs and executes ctags indexing sources.
321impl CtagsSymbolIndexer {
322    /// Creates an indexer that parses supplied JSON Lines without subprocess I/O.
323    ///
324    /// Invalid JSON lines and entries with absent/empty names are silently
325    /// skipped at indexing time.
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// use ailloli_ui_editor::{CtagsSymbolIndexer, Document, DocumentId, SymbolIndexer};
331    /// use ailloli_ui_text::TextBuffer;
332    /// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn f() {}"));
333    /// let mut indexer = CtagsSymbolIndexer::from_json_lines("not json\n{\"name\":\"f\",\"kind\":\"function\",\"line\":1}");
334    /// assert_eq!(indexer.index_document(&document).symbols.len(), 1);
335    /// ```
336    pub fn from_json_lines(json_lines: impl Into<String>) -> Self {
337        Self {
338            source: CtagsSymbolSource::JsonLines(json_lines.into()),
339        }
340    }
341
342    /// Creates an indexer that spawns the configured ctags binary per document.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use ailloli_ui_editor::{CtagsRunnerConfig, CtagsSymbolIndexer};
348    /// let indexer = CtagsSymbolIndexer::from_runner_config(CtagsRunnerConfig::default());
349    /// assert!(format!("{indexer:?}").contains("Runner"));
350    /// ```
351    pub fn from_runner_config(config: CtagsRunnerConfig) -> Self {
352        Self {
353            source: CtagsSymbolSource::Runner(config),
354        }
355    }
356
357    /// Indexes a document while preserving external runner failures.
358    ///
359    /// Fixture mode parses in memory and currently cannot return an error.
360    /// Runner mode writes a predictable temp path based on document ID/version,
361    /// spawns ctags with JSON output, polls every 5 ms until the configured
362    /// timeout, then captures stdout/stderr. The stdout limit is checked only
363    /// after capture; stderr is unbounded. Concurrent identical documents can
364    /// collide, and write/spawn failure may leave the temp file behind.
365    ///
366    /// # Errors
367    ///
368    /// Returns typed spawn, timeout, status, output-size, I/O, or UTF-8 errors.
369    /// Invalid individual JSON lines are still skipped rather than returned.
370    ///
371    /// # Examples
372    ///
373    /// ```
374    /// use ailloli_ui_editor::{CtagsSymbolIndexer, Document, DocumentId};
375    /// use ailloli_ui_text::TextBuffer;
376    /// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn f() {}"));
377    /// let mut indexer = CtagsSymbolIndexer::from_json_lines(r#"{"name":"f","kind":"function","line":1}"#);
378    /// assert_eq!(indexer.try_index_document(&document).unwrap().symbols.len(), 1);
379    /// ```
380    pub fn try_index_document(
381        &mut self,
382        document: &Document,
383    ) -> Result<CodeFileSummary, CtagsError> {
384        let json_lines = match &self.source {
385            CtagsSymbolSource::JsonLines(json_lines) => json_lines.clone(),
386            CtagsSymbolSource::Runner(config) => run_ctags(document, config)?,
387        };
388        Ok(summary_from_ctags_json_lines(document, &json_lines))
389    }
390}
391
392/// Produces an empty metadata-preserving summary on any ctags runner error.
393impl SymbolIndexer for CtagsSymbolIndexer {
394    /// Indexes infallibly, suppressing [`CtagsError`] into zero symbols/edges.
395    fn index_document(&mut self, document: &Document) -> CodeFileSummary {
396        self.try_index_document(document)
397            .unwrap_or_else(|_| empty_summary(document))
398    }
399}
400
401/// Indexes with Tree-sitter-first Rust fallback when available.
402///
403/// With `tree_sitter`, Rust first uses its parser and returns immediately if at
404/// least one symbol was found; parser failure or an empty result falls back to
405/// ctags. Other languages, and all builds without that feature, use ctags
406/// directly. Ctags errors are suppressed by its trait implementation.
407///
408/// # Examples
409///
410/// ```
411/// use ailloli_ui_editor::{index_symbols_with_fallback, CtagsSymbolIndexer, Document, DocumentId, EditorLanguage, SymbolSource};
412/// use ailloli_ui_text::TextBuffer;
413/// let document = Document::new(DocumentId(1), TextBuffer::from_string("function run() {}")) .with_language(EditorLanguage::JavaScript);
414/// let mut ctags = CtagsSymbolIndexer::from_json_lines(r#"{"name":"run","kind":"function","line":1}"#);
415/// assert_eq!(index_symbols_with_fallback(&document, &mut ctags).symbols[0].source, SymbolSource::Ctags);
416/// ```
417pub fn index_symbols_with_fallback(
418    document: &Document,
419    ctags: &mut CtagsSymbolIndexer,
420) -> CodeFileSummary {
421    #[cfg(feature = "tree_sitter")]
422    if document.language == EditorLanguage::Rust {
423        let mut tree_sitter = TreeSitterRustSymbolIndexer;
424        let summary = tree_sitter.index_document(document);
425        if !summary.symbols.is_empty() {
426            return summary;
427        }
428    }
429    ctags.index_document(document)
430}
431
432/// Executes one external ctags process and returns UTF-8 JSON Lines.
433///
434/// # Errors
435///
436/// Returns [`CtagsError::MissingBinary`] when the command is absent,
437/// [`CtagsError::Timeout`] when it exceeds the configured deadline,
438/// [`CtagsError::OutputTooLarge`] when stdout exceeds its byte budget,
439/// [`CtagsError::NonZeroStatus`] for an unsuccessful exit,
440/// [`CtagsError::Utf8`] for non-UTF-8 stdout, or [`CtagsError::Io`] for temp-file
441/// and child-process I/O failures.
442fn run_ctags(document: &Document, config: &CtagsRunnerConfig) -> Result<String, CtagsError> {
443    let temp_path = ctags_temp_path(document);
444    std::fs::write(&temp_path, document.buffer.as_str())
445        .map_err(|err| CtagsError::Io(err.to_string()))?;
446    let mut child = Command::new(&config.binary)
447        .arg("--output-format=json")
448        .arg("--fields=+nksSzt")
449        .arg("--extras=+q")
450        .arg("-f")
451        .arg("-")
452        .arg(&temp_path)
453        .stdin(Stdio::null())
454        .stdout(Stdio::piped())
455        .stderr(Stdio::piped())
456        .spawn()
457        .map_err(|err| {
458            if err.kind() == std::io::ErrorKind::NotFound {
459                CtagsError::MissingBinary(config.binary.display().to_string())
460            } else {
461                CtagsError::Io(err.to_string())
462            }
463        })?;
464
465    let started = Instant::now();
466    loop {
467        match child.try_wait() {
468            Ok(Some(_)) => break,
469            Ok(None) if started.elapsed() >= config.timeout => {
470                let _ = child.kill();
471                let _ = child.wait();
472                let _ = std::fs::remove_file(&temp_path);
473                return Err(CtagsError::Timeout {
474                    timeout: config.timeout,
475                });
476            }
477            Ok(None) => std::thread::sleep(Duration::from_millis(5)),
478            Err(err) => {
479                let _ = child.kill();
480                let _ = std::fs::remove_file(&temp_path);
481                return Err(CtagsError::Io(err.to_string()));
482            }
483        }
484    }
485
486    let output = child
487        .wait_with_output()
488        .map_err(|err| CtagsError::Io(err.to_string()))?;
489    let _ = std::fs::remove_file(&temp_path);
490    if output.stdout.len() > config.max_stdout_bytes {
491        return Err(CtagsError::OutputTooLarge {
492            len: output.stdout.len(),
493            max: config.max_stdout_bytes,
494        });
495    }
496    if !output.status.success() {
497        return Err(CtagsError::NonZeroStatus {
498            code: output.status.code(),
499            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
500        });
501    }
502    String::from_utf8(output.stdout).map_err(|err| CtagsError::Utf8(err.to_string()))
503}
504
505/// Builds the predictable process temp path for a document snapshot.
506fn ctags_temp_path(document: &Document) -> PathBuf {
507    let ext = document
508        .path
509        .as_deref()
510        .and_then(|path| path.extension())
511        .and_then(|ext| ext.to_str())
512        .unwrap_or("txt");
513    std::env::temp_dir().join(format!(
514        "ailloli_ui_ctags_{}_{}.{}",
515        document.id.0, document.version.0, ext
516    ))
517}
518
519/// External universal-ctags process limits and executable.
520///
521/// The default command is `ctags`, timeout is two seconds, and accepted stdout
522/// is at most 512 KiB. The limit does not cover stderr and is enforced after the
523/// child exits rather than while streaming.
524///
525/// # Examples
526///
527/// ```
528/// use std::time::Duration;
529/// use ailloli_ui_editor::CtagsRunnerConfig;
530/// let config = CtagsRunnerConfig::default();
531/// assert_eq!(config.binary.to_str(), Some("ctags"));
532/// assert_eq!(config.timeout, Duration::from_secs(2));
533/// assert_eq!(config.max_stdout_bytes, 512 * 1024);
534/// ```
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct CtagsRunnerConfig {
537    /// Executable path or command name passed directly to [`Command::new`].
538    pub binary: PathBuf,
539    /// Maximum wall time polled in five-millisecond intervals.
540    pub timeout: Duration,
541    /// Maximum captured stdout bytes accepted after process completion.
542    pub max_stdout_bytes: usize,
543}
544
545/// Supplies the standard `ctags` command and conservative process limits.
546impl Default for CtagsRunnerConfig {
547    /// Returns `ctags`, two seconds, and 512 KiB stdout.
548    fn default() -> Self {
549        Self {
550            binary: PathBuf::from("ctags"),
551            timeout: Duration::from_secs(2),
552            max_stdout_bytes: 512 * 1024,
553        }
554    }
555}
556
557/// Typed failure from the external ctags runner.
558///
559/// # Examples
560///
561/// ```
562/// use std::time::Duration;
563/// use ailloli_ui_editor::CtagsError;
564/// assert_eq!(CtagsError::Timeout { timeout: Duration::from_millis(10) }, CtagsError::Timeout { timeout: Duration::from_millis(10) });
565/// ```
566#[derive(Debug, Clone, PartialEq, Eq)]
567pub enum CtagsError {
568    /// Executable lookup failed; contains the configured binary text.
569    MissingBinary(String),
570    /// Child exceeded the configured wall-time limit.
571    Timeout {
572        /// Configured duration reached before observing process exit.
573        timeout: Duration,
574    },
575    /// Captured stdout exceeded the configured byte limit.
576    OutputTooLarge {
577        /// Actual captured stdout byte length.
578        len: usize,
579        /// Configured accepted stdout byte limit.
580        max: usize,
581    },
582    /// Child exited unsuccessfully; signal exits have `code = None`.
583    NonZeroStatus {
584        /// Platform exit code, or `None` when unavailable (for example a signal).
585        code: Option<i32>,
586        /// Lossy UTF-8 decoding of complete captured stderr.
587        stderr: String,
588    },
589    /// Temp-file, spawn, wait, or process-control I/O failure.
590    Io(String),
591    /// Successful stdout was not valid UTF-8.
592    Utf8(String),
593}
594
595#[cfg(feature = "tree_sitter")]
596/// Rust parser-backed symbol and call-graph indexer.
597///
598/// It extracts modules, imports, structs/fields, enums/variants, traits, impls,
599/// functions/methods, constants/statics, type aliases, macros, documentation,
600/// signatures, containment, and resolved call edges. Parser setup/parse failure
601/// falls back to the lexical Rust summary.
602///
603/// # Examples
604///
605/// ```
606/// use ailloli_ui_editor::{code::TreeSitterRustSymbolIndexer, Document, DocumentId, EditorLanguage, SymbolIndexer, SymbolKind};
607/// use ailloli_ui_text::TextBuffer;
608/// let document = Document::new(DocumentId(1), TextBuffer::from_string("struct App { value: usize }")) .with_language(EditorLanguage::Rust);
609/// let summary = TreeSitterRustSymbolIndexer.index_document(&document);
610/// assert!(summary.symbols.iter().any(|symbol| symbol.kind == SymbolKind::Struct && symbol.name == "App"));
611/// assert!(summary.symbols.iter().any(|symbol| symbol.kind == SymbolKind::Field && symbol.name == "value"));
612/// ```
613#[derive(Debug, Default)]
614pub struct TreeSitterRustSymbolIndexer;
615
616#[cfg(feature = "tree_sitter")]
617/// Parses Rust and returns a deterministic local symbol graph.
618impl SymbolIndexer for TreeSitterRustSymbolIndexer {
619    /// Uses Tree-sitter, falling back lexically only on parser setup/parse failure.
620    fn index_document(&mut self, document: &Document) -> CodeFileSummary {
621        let text = document.buffer.as_str();
622        let mut parser = tree_sitter::Parser::new();
623        let language = tree_sitter_rust::LANGUAGE.into();
624        if parser.set_language(&language).is_err() {
625            return summarize_rust_lexical(document);
626        }
627        let Some(tree) = parser.parse(&text, None) else {
628            return summarize_rust_lexical(document);
629        };
630
631        let symbols = collect_tree_sitter_rust_symbols(tree.root_node(), &text, document.language);
632        let mut edges = containment_edges(&symbols);
633        edges.extend(collect_tree_sitter_rust_call_edges(
634            tree.root_node(),
635            &text,
636            &symbols,
637        ));
638        normalize_edges(&mut edges);
639
640        CodeFileSummary {
641            symbols,
642            edges,
643            ..empty_summary(document)
644        }
645    }
646}
647
648/// Builds a deterministic, line-oriented Rust symbol summary.
649///
650/// Only trimmed lines beginning exactly with `use `, `fn `, `struct `, `enum `,
651/// or `trait ` are recognized, so visibility/modifier prefixes are ignored.
652/// Symbols have no parents/signatures/docs and use one-based IDs. One root
653/// containment/import edge is emitted per symbol. Calls are textually detected
654/// as `name(` outside the symbol's own name range, at most once per function,
655/// with root ID zero as caller; comments/strings can cause false positives.
656///
657/// # Examples
658///
659/// ```
660/// use ailloli_ui_editor::{code::symbols::summarize_rust_lexical, Document, DocumentId, EditorLanguage, SymbolEdgeKind, SymbolKind};
661/// use ailloli_ui_text::TextBuffer;
662/// let document = Document::new(DocumentId(1), TextBuffer::from_string("struct App;\nfn run() {}\nfn main() { run(); }")) .with_language(EditorLanguage::Rust);
663/// let summary = summarize_rust_lexical(&document);
664/// assert!(summary.symbols.iter().any(|s| s.kind == SymbolKind::Struct && s.name == "App"));
665/// assert!(summary.edges.iter().any(|e| e.kind == SymbolEdgeKind::Calls));
666/// ```
667pub fn summarize_rust_lexical(document: &Document) -> CodeFileSummary {
668    let text = document.buffer.as_str();
669    let mut symbols = Vec::new();
670    for (line_start, line) in lines_with_offsets(&text) {
671        let trimmed = line.trim_start();
672        let leading = line.len() - trimmed.len();
673        let start = line_start + leading;
674        if let Some(rest) = trimmed.strip_prefix("use ") {
675            let rest = rest.trim();
676            let name = rest
677                .trim_end_matches(';')
678                .rsplit("::")
679                .next()
680                .unwrap_or(rest)
681                .trim()
682                .to_string();
683            push_symbol(
684                &mut symbols,
685                document.language,
686                SymbolKind::Import,
687                name,
688                start,
689                line,
690            );
691        } else if let Some(rest) = trimmed.strip_prefix("fn ") {
692            let name = take_ident(rest);
693            push_symbol(
694                &mut symbols,
695                document.language,
696                SymbolKind::Function,
697                name,
698                start,
699                line,
700            );
701        } else if let Some(rest) = trimmed.strip_prefix("struct ") {
702            let name = take_ident(rest);
703            push_symbol(
704                &mut symbols,
705                document.language,
706                SymbolKind::Struct,
707                name,
708                start,
709                line,
710            );
711        } else if let Some(rest) = trimmed.strip_prefix("enum ") {
712            let name = take_ident(rest);
713            push_symbol(
714                &mut symbols,
715                document.language,
716                SymbolKind::Enum,
717                name,
718                start,
719                line,
720            );
721        } else if let Some(rest) = trimmed.strip_prefix("trait ") {
722            let name = take_ident(rest);
723            push_symbol(
724                &mut symbols,
725                document.language,
726                SymbolKind::Trait,
727                name,
728                start,
729                line,
730            );
731        }
732    }
733
734    let mut edges = containment_edges(&symbols);
735    edges.extend(call_edges(&text, &symbols));
736    normalize_edges(&mut edges);
737
738    CodeFileSummary {
739        symbols,
740        edges,
741        ..empty_summary(document)
742    }
743}
744
745#[cfg(feature = "tree_sitter")]
746/// Traverses a Rust syntax tree and returns symbols in preorder.
747fn collect_tree_sitter_rust_symbols(
748    root: tree_sitter::Node<'_>,
749    text: &str,
750    language: EditorLanguage,
751) -> Vec<CodeSymbol> {
752    let mut collector = TreeSitterSymbolCollector {
753        text,
754        language,
755        symbols: Vec::new(),
756    };
757    collector.collect(root, None);
758    collector.symbols
759}
760
761#[cfg(feature = "tree_sitter")]
762/// Stateful preorder collector that assigns positional symbol IDs.
763struct TreeSitterSymbolCollector<'a> {
764    /// Exact parsed source text.
765    text: &'a str,
766    /// Language copied into every produced symbol.
767    language: EditorLanguage,
768    /// Symbols accumulated in preorder.
769    symbols: Vec<CodeSymbol>,
770}
771
772#[cfg(feature = "tree_sitter")]
773/// Recursively converts supported Rust nodes into symbols.
774impl<'a> TreeSitterSymbolCollector<'a> {
775    /// Visits one node and descendants under the nearest container parent.
776    fn collect(&mut self, node: tree_sitter::Node<'_>, parent: Option<SymbolId>) {
777        let mut next_parent = parent;
778        if let Some(symbol) = self.symbol_for_node(node, parent) {
779            next_parent = symbol_container_parent(symbol.kind).then_some(symbol.id);
780            self.symbols.push(symbol);
781        }
782
783        let mut cursor = node.walk();
784        for child in node.children(&mut cursor) {
785            self.collect(child, next_parent.or(parent));
786        }
787    }
788
789    /// Converts one supported syntax node into the next positional symbol.
790    fn symbol_for_node(
791        &self,
792        node: tree_sitter::Node<'_>,
793        parent: Option<SymbolId>,
794    ) -> Option<CodeSymbol> {
795        let spec = tree_sitter_rust_symbol_spec(
796            self.text,
797            node,
798            parent.and_then(|id| self.kind_for_id(id)),
799        )?;
800        let name_node = spec.name_node?;
801        let name = spec
802            .name_override
803            .unwrap_or_else(|| node_text(self.text, name_node).trim().to_string());
804        if name.is_empty() {
805            return None;
806        }
807        let id = SymbolId(self.symbols.len() as u64 + 1);
808        Some(CodeSymbol {
809            id,
810            name,
811            kind: spec.kind,
812            language: self.language,
813            range: valid_range(self.text, node.start_byte()..node.end_byte()),
814            selection_range: valid_range(self.text, name_node.start_byte()..name_node.end_byte()),
815            parent,
816            signature: signature_for_symbol(self.text, node, spec.kind),
817            docs: docs_for_symbol(self.text, node),
818            source: SymbolSource::TreeSitter,
819        })
820    }
821
822    /// Looks up a previously collected symbol category by ID.
823    fn kind_for_id(&self, id: SymbolId) -> Option<SymbolKind> {
824        self.symbols
825            .iter()
826            .find(|symbol| symbol.id == id)
827            .map(|symbol| symbol.kind)
828    }
829}
830
831#[cfg(feature = "tree_sitter")]
832/// Classification and name extraction recipe for one Rust syntax node.
833struct RustSymbolSpec<'tree> {
834    /// Resulting local symbol category.
835    kind: SymbolKind,
836    /// Node whose bytes define the selection range.
837    name_node: Option<tree_sitter::Node<'tree>>,
838    /// Optional derived name replacing raw name-node text.
839    name_override: Option<String>,
840}
841
842#[cfg(feature = "tree_sitter")]
843/// Classifies supported Rust declaration nodes and selects their names.
844fn tree_sitter_rust_symbol_spec<'tree>(
845    text: &str,
846    node: tree_sitter::Node<'tree>,
847    parent_kind: Option<SymbolKind>,
848) -> Option<RustSymbolSpec<'tree>> {
849    let name = || node.child_by_field_name("name");
850    match node.kind() {
851        "mod_item" => Some(RustSymbolSpec {
852            kind: SymbolKind::Module,
853            name_node: name(),
854            name_override: None,
855        }),
856        "use_declaration" => Some(RustSymbolSpec {
857            kind: SymbolKind::Import,
858            name_node: Some(node),
859            name_override: Some(import_name(node_text(text, node))),
860        }),
861        "struct_item" => Some(RustSymbolSpec {
862            kind: SymbolKind::Struct,
863            name_node: name(),
864            name_override: None,
865        }),
866        "enum_item" => Some(RustSymbolSpec {
867            kind: SymbolKind::Enum,
868            name_node: name(),
869            name_override: None,
870        }),
871        "trait_item" => Some(RustSymbolSpec {
872            kind: SymbolKind::Trait,
873            name_node: name(),
874            name_override: None,
875        }),
876        "impl_item" => {
877            let type_node = impl_type_node(node).or_else(name);
878            let type_text = type_node
879                .map(|type_node| compact_node_text(node_text(text, type_node)))
880                .unwrap_or_else(|| "impl".to_string());
881            Some(RustSymbolSpec {
882                kind: SymbolKind::Impl,
883                name_node: type_node,
884                name_override: Some(format!("impl {type_text}")),
885            })
886        }
887        "function_item" | "function_signature_item" => Some(RustSymbolSpec {
888            kind: if matches!(parent_kind, Some(SymbolKind::Impl | SymbolKind::Trait)) {
889                SymbolKind::Method
890            } else {
891                SymbolKind::Function
892            },
893            name_node: name(),
894            name_override: None,
895        }),
896        "const_item" | "static_item" => Some(RustSymbolSpec {
897            kind: SymbolKind::Constant,
898            name_node: name(),
899            name_override: None,
900        }),
901        "type_item" => Some(RustSymbolSpec {
902            kind: SymbolKind::TypeAlias,
903            name_node: name(),
904            name_override: None,
905        }),
906        "macro_definition" => Some(RustSymbolSpec {
907            kind: SymbolKind::Macro,
908            name_node: name(),
909            name_override: None,
910        }),
911        "field_declaration" if parent_kind == Some(SymbolKind::Struct) => Some(RustSymbolSpec {
912            kind: SymbolKind::Field,
913            name_node: name(),
914            name_override: None,
915        }),
916        "enum_variant" if parent_kind == Some(SymbolKind::Enum) => Some(RustSymbolSpec {
917            kind: SymbolKind::EnumVariant,
918            name_node: name(),
919            name_override: None,
920        }),
921        _ => None,
922    }
923}
924
925#[cfg(feature = "tree_sitter")]
926/// Reports whether a symbol can become the parent of descendant declarations.
927fn symbol_container_parent(kind: SymbolKind) -> bool {
928    matches!(
929        kind,
930        SymbolKind::Module
931            | SymbolKind::Impl
932            | SymbolKind::Struct
933            | SymbolKind::Enum
934            | SymbolKind::Trait
935    )
936}
937
938#[cfg(feature = "tree_sitter")]
939/// Finds the implemented type node in an impl declaration.
940fn impl_type_node(node: tree_sitter::Node<'_>) -> Option<tree_sitter::Node<'_>> {
941    node.child_by_field_name("type").or_else(|| {
942        let mut cursor = node.walk();
943        let found = node.children(&mut cursor).find(|child| {
944            matches!(
945                child.kind(),
946                "type_identifier"
947                    | "generic_type"
948                    | "scoped_type_identifier"
949                    | "reference_type"
950                    | "primitive_type"
951            )
952        });
953        found
954    })
955}
956
957#[cfg(feature = "tree_sitter")]
958/// Returns valid source text for a syntax node, or empty on an invalid range.
959fn node_text<'a>(text: &'a str, node: tree_sitter::Node<'_>) -> &'a str {
960    text.get(node.start_byte()..node.end_byte())
961        .unwrap_or_default()
962}
963
964#[cfg(feature = "tree_sitter")]
965/// Collapses all whitespace runs to single ASCII spaces.
966fn compact_node_text(text: &str) -> String {
967    text.split_whitespace().collect::<Vec<_>>().join(" ")
968}
969
970#[cfg(feature = "tree_sitter")]
971/// Derives the final imported component from a Rust use declaration.
972fn import_name(text: &str) -> String {
973    text.trim()
974        .trim_start_matches("pub")
975        .trim_start()
976        .trim_start_matches("use")
977        .trim()
978        .trim_end_matches(';')
979        .rsplit("::")
980        .next()
981        .unwrap_or(text)
982        .trim_matches(|ch: char| matches!(ch, '{' | '}' | '*' | ' ' | ';'))
983        .to_string()
984}
985
986#[cfg(feature = "tree_sitter")]
987/// Clamps range endpoints down to valid UTF-8 boundaries inside source length.
988fn valid_range(text: &str, range: Range<usize>) -> Range<usize> {
989    let mut start = range.start.min(text.len());
990    while start > 0 && !text.is_char_boundary(start) {
991        start -= 1;
992    }
993    let mut end = range.end.min(text.len());
994    while end > start && !text.is_char_boundary(end) {
995        end -= 1;
996    }
997    start..end
998}
999
1000#[cfg(feature = "tree_sitter")]
1001/// Extracts a compact declaration header or field/import signature.
1002fn signature_for_symbol(
1003    text: &str,
1004    node: tree_sitter::Node<'_>,
1005    kind: SymbolKind,
1006) -> Option<String> {
1007    if matches!(
1008        kind,
1009        SymbolKind::Field | SymbolKind::EnumVariant | SymbolKind::Import
1010    ) {
1011        return Some(compact_node_text(
1012            node_text(text, node)
1013                .trim()
1014                .trim_end_matches(',')
1015                .trim_end_matches(';'),
1016        ));
1017    }
1018    let raw = node_text(text, node).trim();
1019    if raw.is_empty() {
1020        return None;
1021    }
1022    let end = raw
1023        .find('{')
1024        .or_else(|| raw.find(';').map(|idx| idx + 1))
1025        .unwrap_or_else(|| raw.lines().next().map_or(raw.len(), str::len));
1026    let signature = compact_node_text(raw[..end].trim().trim_end_matches('{').trim());
1027    (!signature.is_empty()).then_some(signature)
1028}
1029
1030#[cfg(feature = "tree_sitter")]
1031/// Collects contiguous preceding Rust doc comments and `#[doc = "..."]` lines.
1032fn docs_for_symbol(text: &str, node: tree_sitter::Node<'_>) -> Option<String> {
1033    let prefix = &text[..node.start_byte().min(text.len())];
1034    let mut docs = Vec::new();
1035    for line in prefix.lines().rev() {
1036        let trimmed = line.trim();
1037        if trimmed.is_empty() {
1038            if docs.is_empty() {
1039                continue;
1040            }
1041            break;
1042        }
1043        if let Some(doc) = trimmed.strip_prefix("///") {
1044            docs.push(doc.trim().to_string());
1045        } else if let Some(doc) = trimmed.strip_prefix("//!") {
1046            docs.push(doc.trim().to_string());
1047        } else if let Some(doc) = trimmed
1048            .strip_prefix("#[doc = \"")
1049            .and_then(|rest| rest.strip_suffix("\"]"))
1050        {
1051            docs.push(doc.trim().to_string());
1052        } else if trimmed.starts_with("#[") {
1053            continue;
1054        } else {
1055            break;
1056        }
1057    }
1058    docs.reverse();
1059    (!docs.is_empty()).then_some(docs.join("\n"))
1060}
1061
1062/// Parses universal-ctags JSON Lines into local symbol records.
1063///
1064/// Invalid JSON lines and entries with missing/empty names are skipped. IDs are
1065/// assigned after filtering, ranges derive from one-based `line`/`end` fields,
1066/// kinds use a fixed mapping, signature falls back through pattern then typeref,
1067/// and parents resolve only to earlier scope-matching entries. No errors are
1068/// returned and containment edges are not included in this symbol-only result.
1069///
1070/// # Examples
1071///
1072/// ```
1073/// use ailloli_ui_editor::{parse_ctags_json_lines, Document, DocumentId, EditorLanguage, SymbolKind, SymbolSource};
1074/// use ailloli_ui_text::TextBuffer;
1075/// let document = Document::new(DocumentId(1), TextBuffer::from_string("struct App;\n")) .with_language(EditorLanguage::Rust);
1076/// let symbols = parse_ctags_json_lines(&document, "not json\n{\"name\":\"App\",\"kind\":\"struct\",\"line\":1}");
1077/// assert_eq!(symbols.len(), 1);
1078/// assert_eq!((symbols[0].kind, symbols[0].source), (SymbolKind::Struct, SymbolSource::Ctags));
1079/// assert_eq!(symbols[0].selection_range, 7..10);
1080/// ```
1081pub fn parse_ctags_json_lines(document: &Document, json_lines: &str) -> Vec<CodeSymbol> {
1082    parse_ctags_entries(document, json_lines)
1083        .into_iter()
1084        .map(|entry| entry.symbol)
1085        .collect()
1086}
1087
1088/// Parses ctags symbols and adds normalized containment/import edges.
1089fn summary_from_ctags_json_lines(document: &Document, json_lines: &str) -> CodeFileSummary {
1090    let symbols = parse_ctags_json_lines(document, json_lines);
1091    let mut edges = containment_edges(&symbols);
1092    normalize_edges(&mut edges);
1093    CodeFileSummary {
1094        symbols,
1095        edges,
1096        ..empty_summary(document)
1097    }
1098}
1099
1100/// Parses, filters, converts, and parent-links ctags entries.
1101fn parse_ctags_entries(document: &Document, json_lines: &str) -> Vec<CtagsParsedSymbol> {
1102    let entries: Vec<CtagsParsedSymbol> = json_lines
1103        .lines()
1104        .filter_map(|line| serde_json::from_str::<CtagsEntry>(line).ok())
1105        .filter(|entry| entry.name.as_deref().is_some_and(|name| !name.is_empty()))
1106        .enumerate()
1107        .map(|(idx, entry)| ctags_entry_to_symbol(document, idx, entry))
1108        .collect();
1109    attach_ctags_parents(entries)
1110}
1111
1112/// Converted ctags symbol plus unresolved scope metadata.
1113#[derive(Debug, Clone)]
1114struct CtagsParsedSymbol {
1115    /// Local symbol record.
1116    symbol: CodeSymbol,
1117    /// Optional producer scope string.
1118    scope: Option<String>,
1119    /// Optional producer scope-kind string.
1120    scope_kind: Option<String>,
1121}
1122
1123#[allow(dead_code)]
1124/// Legacy symbol-only ctags parser retained for regression comparison.
1125fn parse_ctags_json_lines_legacy(document: &Document, json_lines: &str) -> Vec<CodeSymbol> {
1126    json_lines
1127        .lines()
1128        .filter_map(|line| serde_json::from_str::<CtagsEntry>(line).ok())
1129        .filter(|entry| entry.name.as_deref().is_some_and(|name| !name.is_empty()))
1130        .enumerate()
1131        .map(|(idx, entry)| ctags_entry_to_symbol(document, idx, entry).symbol)
1132        .collect()
1133}
1134
1135/// Builds a zero-symbol, zero-edge summary preserving document metadata.
1136fn empty_summary(document: &Document) -> CodeFileSummary {
1137    CodeFileSummary {
1138        document_id: document.id,
1139        path: document.path.clone(),
1140        language: document.language,
1141        version: document.version,
1142        symbols: Vec::new(),
1143        edges: Vec::new(),
1144    }
1145}
1146
1147/// Deserializable subset of universal-ctags JSON output.
1148#[derive(Debug, serde::Deserialize)]
1149struct CtagsEntry {
1150    /// Symbol name; absent/empty entries are discarded.
1151    name: Option<String>,
1152    /// Long or short ctags kind name.
1153    kind: Option<String>,
1154    /// One-based starting line.
1155    line: Option<usize>,
1156    /// One-based inclusive ending line.
1157    end: Option<usize>,
1158    /// Source-pattern fallback for signature text.
1159    pattern: Option<String>,
1160    /// Preferred signature text.
1161    signature: Option<String>,
1162    /// Type-reference fallback for signature text.
1163    typeref: Option<String>,
1164    /// Optional containing scope string.
1165    scope: Option<String>,
1166    /// Optional containing scope category.
1167    #[serde(rename = "scopeKind")]
1168    scope_kind: Option<String>,
1169    /// Producer language, currently parsed then ignored.
1170    language: Option<String>,
1171    /// Producer roles, currently parsed then ignored.
1172    roles: Option<String>,
1173}
1174
1175/// Converts one filtered ctags entry to a positional local symbol.
1176fn ctags_entry_to_symbol(document: &Document, idx: usize, entry: CtagsEntry) -> CtagsParsedSymbol {
1177    let name = entry.name.unwrap_or_default();
1178    let text = document.buffer.as_str();
1179    let range = ctags_full_range(&text, entry.line, entry.end);
1180    let selection_range = ctags_selection_range(&text, &name, range.clone());
1181    let signature = entry
1182        .signature
1183        .or(entry.pattern)
1184        .or(entry.typeref)
1185        .map(|value| value.trim().to_string())
1186        .filter(|value| !value.is_empty());
1187    let _ctags_language = entry.language;
1188    let _ctags_roles = entry.roles;
1189    CtagsParsedSymbol {
1190        symbol: CodeSymbol {
1191            id: SymbolId(idx as u64 + 1),
1192            name,
1193            kind: ctags_kind_to_symbol_kind(entry.kind.as_deref()),
1194            language: document.language,
1195            selection_range,
1196            range,
1197            parent: None,
1198            signature,
1199            docs: None,
1200            source: SymbolSource::Ctags,
1201        },
1202        scope: entry.scope,
1203        scope_kind: entry.scope_kind,
1204    }
1205}
1206
1207/// Resolves each ctags scope against the nearest earlier matching entry.
1208fn attach_ctags_parents(mut entries: Vec<CtagsParsedSymbol>) -> Vec<CtagsParsedSymbol> {
1209    for idx in 0..entries.len() {
1210        let Some(scope) = entries[idx].scope.clone() else {
1211            continue;
1212        };
1213        let scope_name = scope.rsplit("::").next().unwrap_or(scope.as_str());
1214        let scope_kind = entries[idx]
1215            .scope_kind
1216            .as_deref()
1217            .map(|kind| ctags_kind_to_symbol_kind(Some(kind)));
1218        let parent = entries
1219            .iter()
1220            .take(idx)
1221            .rev()
1222            .find(|entry| {
1223                entry.symbol.name == scope_name
1224                    && scope_kind
1225                        .is_none_or(|kind| kind == SymbolKind::Unknown || kind == entry.symbol.kind)
1226            })
1227            .map(|entry| entry.symbol.id);
1228        entries[idx].symbol.parent = parent;
1229    }
1230    entries
1231}
1232
1233/// Maps supported long/short ctags kinds to local categories.
1234fn ctags_kind_to_symbol_kind(kind: Option<&str>) -> SymbolKind {
1235    match kind.unwrap_or_default() {
1236        "function" | "f" => SymbolKind::Function,
1237        "method" | "m" => SymbolKind::Method,
1238        "struct" | "s" => SymbolKind::Struct,
1239        "enum" | "e" => SymbolKind::Enum,
1240        "trait" | "t" => SymbolKind::Trait,
1241        "impl" => SymbolKind::Impl,
1242        "class" | "c" => SymbolKind::Type,
1243        "type" | "typedef" => SymbolKind::TypeAlias,
1244        "interface" | "i" => SymbolKind::Interface,
1245        "field" | "member" | "F" => SymbolKind::Field,
1246        "enumerator" | "enumConstant" => SymbolKind::EnumVariant,
1247        "variable" | "v" => SymbolKind::Variable,
1248        "constant" | "C" => SymbolKind::Constant,
1249        "macro" | "macrodef" => SymbolKind::Macro,
1250        "namespace" | "module" | "n" => SymbolKind::Namespace,
1251        "import" | "use" => SymbolKind::Import,
1252        _ => SymbolKind::Unknown,
1253    }
1254}
1255
1256/// Converts optional one-based ctags line bounds to a document byte range.
1257fn ctags_full_range(text: &str, line: Option<usize>, end: Option<usize>) -> Range<usize> {
1258    let start = line
1259        .and_then(|line| line_start_offset(text, line.saturating_sub(1)))
1260        .unwrap_or(0)
1261        .min(text.len());
1262    let end = end
1263        .and_then(|line| line_start_offset(text, line))
1264        .unwrap_or_else(|| line_end_offset(text, start))
1265        .min(text.len());
1266    start..end.max(start)
1267}
1268
1269/// Narrows a full ctags range to its first matching name occurrence.
1270fn ctags_selection_range(text: &str, name: &str, range: Range<usize>) -> Range<usize> {
1271    if let Some(name_start) = text[range.clone()].find(name) {
1272        let absolute = range.start + name_start;
1273        absolute..absolute + name.len()
1274    } else {
1275        range
1276    }
1277}
1278
1279#[allow(dead_code)]
1280/// Legacy combined full/selection range helper retained for regression use.
1281fn ctags_range(text: &str, name: &str, line: Option<usize>, end: Option<usize>) -> Range<usize> {
1282    ctags_selection_range(text, name, ctags_full_range(text, line, end))
1283}
1284
1285/// Resolves a zero-based logical line to its starting byte, if present.
1286fn line_start_offset(text: &str, zero_based_line: usize) -> Option<usize> {
1287    if zero_based_line == 0 {
1288        return Some(0);
1289    }
1290    let mut current = 0;
1291    for (offset, ch) in text.char_indices() {
1292        if ch == '\n' {
1293            current += 1;
1294            if current == zero_based_line {
1295                return Some(offset + ch.len_utf8());
1296            }
1297        }
1298    }
1299    None
1300}
1301
1302/// Returns the next newline byte or EOF after a valid starting byte.
1303fn line_end_offset(text: &str, start: usize) -> usize {
1304    text[start..]
1305        .find('\n')
1306        .map(|offset| start + offset)
1307        .unwrap_or(text.len())
1308}
1309
1310/// Emits one root/parent containment or import edge per symbol.
1311fn containment_edges(symbols: &[CodeSymbol]) -> Vec<SymbolEdge> {
1312    symbols
1313        .iter()
1314        .map(|symbol| SymbolEdge {
1315            from: symbol.parent.unwrap_or(SymbolId(0)),
1316            to: symbol.id,
1317            kind: if symbol.kind == SymbolKind::Import {
1318                SymbolEdgeKind::Imports
1319            } else {
1320                SymbolEdgeKind::Contains
1321            },
1322        })
1323        .collect()
1324}
1325
1326/// Sorts edges by category/from/to and removes exact duplicates.
1327fn normalize_edges(edges: &mut Vec<SymbolEdge>) {
1328    edges.sort_by_key(|edge| (symbol_edge_rank(edge.kind), edge.from.0, edge.to.0));
1329    edges.dedup_by_key(|edge| (edge.from, edge.to, edge.kind));
1330}
1331
1332/// Returns deterministic edge-category ordering rank.
1333fn symbol_edge_rank(kind: SymbolEdgeKind) -> u8 {
1334    match kind {
1335        SymbolEdgeKind::Contains => 0,
1336        SymbolEdgeKind::Imports => 1,
1337        SymbolEdgeKind::Calls => 2,
1338        SymbolEdgeKind::References => 3,
1339        SymbolEdgeKind::Extends => 4,
1340        SymbolEdgeKind::Implements => 5,
1341    }
1342}
1343
1344/// Finds coarse root-origin call edges with textual `name(` searches.
1345fn call_edges(text: &str, symbols: &[CodeSymbol]) -> Vec<SymbolEdge> {
1346    let functions: Vec<_> = symbols
1347        .iter()
1348        .filter(|symbol| symbol.kind == SymbolKind::Function)
1349        .collect();
1350    let mut edges = Vec::new();
1351    for function in &functions {
1352        let needle = format!("{}(", function.name);
1353        let mut start = 0;
1354        while let Some(pos) = text[start..].find(&needle) {
1355            let absolute = start + pos;
1356            if !function.selection_range.contains(&absolute) {
1357                edges.push(SymbolEdge {
1358                    from: SymbolId(0),
1359                    to: function.id,
1360                    kind: SymbolEdgeKind::Calls,
1361                });
1362                break;
1363            }
1364            start = absolute + needle.len();
1365        }
1366    }
1367    edges
1368}
1369
1370#[cfg(feature = "tree_sitter")]
1371/// Collects and normalizes resolved Rust call-expression edges.
1372fn collect_tree_sitter_rust_call_edges(
1373    root: tree_sitter::Node<'_>,
1374    text: &str,
1375    symbols: &[CodeSymbol],
1376) -> Vec<SymbolEdge> {
1377    let mut edges = Vec::new();
1378    collect_tree_sitter_rust_call_edges_from_node(root, text, symbols, &mut edges);
1379    normalize_edges(&mut edges);
1380    edges
1381}
1382
1383#[cfg(feature = "tree_sitter")]
1384/// Recursively visits call expressions and appends resolvable edges.
1385fn collect_tree_sitter_rust_call_edges_from_node(
1386    node: tree_sitter::Node<'_>,
1387    text: &str,
1388    symbols: &[CodeSymbol],
1389    edges: &mut Vec<SymbolEdge>,
1390) {
1391    if node.kind() == "call_expression" {
1392        if let Some(edge) = call_edge_for_node(node, text, symbols) {
1393            edges.push(edge);
1394        }
1395    }
1396
1397    let mut cursor = node.walk();
1398    for child in node.children(&mut cursor) {
1399        collect_tree_sitter_rust_call_edges_from_node(child, text, symbols, edges);
1400    }
1401}
1402
1403#[cfg(feature = "tree_sitter")]
1404/// Resolves one non-recursive call expression to a caller/callee edge.
1405fn call_edge_for_node(
1406    node: tree_sitter::Node<'_>,
1407    text: &str,
1408    symbols: &[CodeSymbol],
1409) -> Option<SymbolEdge> {
1410    let caller = caller_for_byte(symbols, node.start_byte())?;
1411    let callee = resolve_callee_symbol(symbols, caller, &call_expression_target(node, text)?)?;
1412    if caller.id == callee.id {
1413        return None;
1414    }
1415    Some(SymbolEdge {
1416        from: caller.id,
1417        to: callee.id,
1418        kind: SymbolEdgeKind::Calls,
1419    })
1420}
1421
1422#[cfg(feature = "tree_sitter")]
1423/// Finds the smallest containing function/method, breaking ties by ID.
1424fn caller_for_byte(symbols: &[CodeSymbol], byte: usize) -> Option<&CodeSymbol> {
1425    symbols
1426        .iter()
1427        .filter(|symbol| {
1428            matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method)
1429                && symbol.range.start <= byte
1430                && byte <= symbol.range.end
1431        })
1432        .min_by_key(|symbol| (symbol.range.end - symbol.range.start, symbol.id.0))
1433}
1434
1435#[cfg(feature = "tree_sitter")]
1436/// Chooses the nearest matching function/method/macro for a call target.
1437fn resolve_callee_symbol<'a>(
1438    symbols: &'a [CodeSymbol],
1439    caller: &CodeSymbol,
1440    target: &CallTarget,
1441) -> Option<&'a CodeSymbol> {
1442    symbols
1443        .iter()
1444        .filter(|symbol| {
1445            symbol.name == target.name
1446                && matches!(
1447                    symbol.kind,
1448                    SymbolKind::Function | SymbolKind::Method | SymbolKind::Macro
1449                )
1450                && target
1451                    .scope
1452                    .as_deref()
1453                    .is_none_or(|scope| symbol_parent_name(symbols, symbol) == Some(scope))
1454        })
1455        .min_by_key(|symbol| {
1456            (
1457                callee_scope_distance(symbols, caller, symbol),
1458                symbol.range.start.abs_diff(caller.range.start),
1459                symbol.id.0,
1460            )
1461        })
1462}
1463
1464#[cfg(feature = "tree_sitter")]
1465/// Ranks callee scope relative to a caller from same parent through unrelated.
1466fn callee_scope_distance(
1467    symbols: &[CodeSymbol],
1468    caller: &CodeSymbol,
1469    callee: &CodeSymbol,
1470) -> usize {
1471    if caller.parent == callee.parent {
1472        return 0;
1473    }
1474    if caller.parent.is_some_and(|parent| parent == callee.id) {
1475        return 1;
1476    }
1477    let caller_ancestors = symbol_ancestors(symbols, caller);
1478    if callee
1479        .parent
1480        .is_some_and(|parent| caller_ancestors.contains(&parent))
1481    {
1482        return 2;
1483    }
1484    if callee.parent.is_none() {
1485        return 3;
1486    }
1487    4
1488}
1489
1490#[cfg(feature = "tree_sitter")]
1491/// Walks parent IDs upward until root or an unresolved parent.
1492fn symbol_ancestors(symbols: &[CodeSymbol], symbol: &CodeSymbol) -> Vec<SymbolId> {
1493    let mut ancestors = Vec::new();
1494    let mut current = symbol.parent;
1495    while let Some(parent) = current {
1496        ancestors.push(parent);
1497        current = symbols
1498            .iter()
1499            .find(|candidate| candidate.id == parent)
1500            .and_then(|candidate| candidate.parent);
1501    }
1502    ancestors
1503}
1504
1505#[cfg(feature = "tree_sitter")]
1506/// Final callable name plus optional explicit scope qualifier.
1507struct CallTarget {
1508    /// Unqualified callable name.
1509    name: String,
1510    /// Explicit scope from a scoped identifier, if any.
1511    scope: Option<String>,
1512}
1513
1514#[cfg(feature = "tree_sitter")]
1515/// Extracts a call target from a Tree-sitter call-expression node.
1516fn call_expression_target(node: tree_sitter::Node<'_>, text: &str) -> Option<CallTarget> {
1517    let function = node.child_by_field_name("function")?;
1518    final_call_target(function, text)
1519}
1520
1521#[cfg(feature = "tree_sitter")]
1522/// Recursively extracts the final callable identifier and optional scope.
1523fn final_call_target(node: tree_sitter::Node<'_>, text: &str) -> Option<CallTarget> {
1524    let name = match node.kind() {
1525        "identifier" | "field_identifier" => node_text(text, node).to_string(),
1526        "scoped_identifier" | "field_expression" => node
1527            .child_by_field_name("name")
1528            .map(|name| node_text(text, name).to_string())
1529            .or_else(|| last_identifier_child(node, text))?,
1530        _ => last_identifier_child(node, text)?,
1531    }
1532    .trim()
1533    .to_string();
1534    if name.is_empty() {
1535        return None;
1536    }
1537    Some(CallTarget {
1538        name,
1539        scope: call_scope_name(node, text),
1540    })
1541}
1542
1543#[cfg(feature = "tree_sitter")]
1544/// Returns the last recursively discoverable child identifier.
1545fn last_identifier_child(node: tree_sitter::Node<'_>, text: &str) -> Option<String> {
1546    let mut cursor = node.walk();
1547    node.children(&mut cursor)
1548        .filter_map(|child| final_call_target(child, text).map(|target| target.name))
1549        .last()
1550}
1551
1552#[cfg(feature = "tree_sitter")]
1553/// Extracts a non-empty qualifier from a scoped identifier.
1554fn call_scope_name(node: tree_sitter::Node<'_>, text: &str) -> Option<String> {
1555    if node.kind() != "scoped_identifier" {
1556        return None;
1557    }
1558    node.child_by_field_name("path")
1559        .or_else(|| node.child_by_field_name("scope"))
1560        .map(|scope| node_text(text, scope).trim().to_string())
1561        .filter(|scope| !scope.is_empty())
1562}
1563
1564#[cfg(feature = "tree_sitter")]
1565/// Resolves a symbol's parent ID to its borrowed name.
1566fn symbol_parent_name<'a>(symbols: &'a [CodeSymbol], symbol: &CodeSymbol) -> Option<&'a str> {
1567    let parent = symbol.parent?;
1568    symbols
1569        .iter()
1570        .find(|candidate| candidate.id == parent)
1571        .map(|candidate| candidate.name.as_str())
1572}
1573
1574/// Appends a non-empty lexical symbol and repairs its selection range.
1575fn push_symbol(
1576    symbols: &mut Vec<CodeSymbol>,
1577    language: EditorLanguage,
1578    kind: SymbolKind,
1579    name: String,
1580    start: usize,
1581    line: &str,
1582) {
1583    if name.is_empty() {
1584        return;
1585    }
1586    let id = SymbolId(symbols.len() as u64 + 1);
1587    let range = start..start + line.trim_end_matches('\n').len();
1588    let name_start = range.start
1589        + line[range.start.saturating_sub(start)..]
1590            .find(&name)
1591            .unwrap_or(0);
1592    symbols.push(CodeSymbol {
1593        id,
1594        name,
1595        kind,
1596        language,
1597        range,
1598        selection_range: name_start..name_start + symbols.last().map_or(0, |_| 0),
1599        parent: None,
1600        signature: None,
1601        docs: None,
1602        source: SymbolSource::Lexical,
1603    });
1604    let last = symbols.last_mut().expect("symbol just pushed");
1605    last.selection_range = name_start..name_start + last.name.len();
1606}
1607
1608/// Takes the leading ASCII identifier-like substring.
1609fn take_ident(rest: &str) -> String {
1610    rest.chars()
1611        .take_while(|ch| *ch == '_' || ch.is_ascii_alphanumeric())
1612        .collect()
1613}
1614
1615/// Iterates newline-inclusive logical lines with starting UTF-8 byte offsets.
1616fn lines_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
1617    let mut offset = 0;
1618    text.split_inclusive('\n').map(move |line| {
1619        let current = offset;
1620        offset += line.len();
1621        (current, line)
1622    })
1623}