Skip to main content

sinter_extract/
extract.rs

1use sinter_core::{
2    Edge, Evidence, FieldBinding, Node, NodeId, Reference, Relation, Span, SymbolKind,
3};
4use streaming_iterator::StreamingIterator;
5use tree_sitter::{Node as TsNode, Parser, Query, QueryCursor};
6
7/// Upper bound on a stored doc body; markdown sections and long
8/// docstrings are truncated here, keeping the beginning.
9const DOC_CAP_BYTES: usize = 4096;
10
11use sinter_core::FileFacts;
12
13use crate::language::LanguageSpec;
14
15#[derive(Debug, thiserror::Error)]
16pub enum ExtractError {
17    #[error("bad grammar or query for {language}: {message}")]
18    Query {
19        language: &'static str,
20        message: String,
21    },
22    #[error("parser returned no tree for {0}")]
23    Parse(String),
24}
25
26/// One reusable extractor per (language, thread): parser and compiled query
27/// are pooled here, never rebuilt per file.
28pub struct Extractor {
29    spec: &'static LanguageSpec,
30    parser: Parser,
31    query: Query,
32    /// Secondary inline grammar (spec.inline): parses designated
33    /// container-node ranges of the primary tree; captures merge into
34    /// the same facts through the same contract.
35    inline: Option<(Parser, Query)>,
36    /// SQL parser + query for `@sql` captures (embedded SQL at query
37    /// sinks): built on first use, shared across files.
38    embedded_sql: Option<(Parser, Query)>,
39}
40
41/// A definition or scope-only entry, pre-qualification.
42struct RawEntry {
43    start: usize,
44    end: usize,
45    name: String,
46    /// None for scope-only entries (e.g. impl blocks).
47    kind: Option<SymbolKind>,
48    /// Extra scope prefix from the same match (e.g. Go receiver type).
49    qualifier: Option<String>,
50    signature: String,
51    doc: Option<String>,
52}
53
54/// A reference site, pre-enclosure.
55struct RawRef {
56    start: usize,
57    end: usize,
58    name: String,
59    path: Option<String>,
60    alias: Option<String>,
61    relation: Relation,
62}
63
64/// A local binding site, pre-scoping.
65struct RawLocal {
66    start: usize,
67    end: usize,
68    name: String,
69    type_name: Option<String>,
70}
71
72/// Everything collect() gathers besides definitions.
73#[derive(Default)]
74struct Collected {
75    refs: Vec<RawRef>,
76    locals: Vec<RawLocal>,
77    /// (span, field name, written type) — owner resolved after entries exist.
78    fields: Vec<(usize, usize, String, String)>,
79    /// (span, embedded type name) — owner resolved after entries exist.
80    embeds: Vec<(usize, usize, String)>,
81    /// (impl block span, trait name) — trait-impl pairing facts.
82    trait_impls: Vec<(usize, usize, String)>,
83    /// Import-alias name spans: identical local captures are the import
84    /// binding itself, not a shadow.
85    alias_spans: Vec<(usize, usize)>,
86    /// Definition name spans: a blanket type-reference capture also lands
87    /// on `struct Foo`'s own name, which is a definition, not a use.
88    def_name_spans: Vec<(usize, usize)>,
89    /// `@sql` capture ranges: string-literal content at known query sinks,
90    /// each re-parsed with the SQL grammar (spans stay file-absolute).
91    sql_ranges: Vec<tree_sitter::Range>,
92    /// Explicit doc captures (`@doc`, e.g. Python docstrings): (span, text).
93    /// Attached to the smallest containing definition, overriding any
94    /// sibling-comment doc.
95    docs: Vec<(usize, usize, String)>,
96}
97
98impl Extractor {
99    pub fn new(spec: &'static LanguageSpec) -> Result<Self, ExtractError> {
100        let language = (spec.grammar)();
101        let mut parser = Parser::new();
102        parser
103            .set_language(&language)
104            .map_err(|e| ExtractError::Query {
105                language: spec.name,
106                message: e.to_string(),
107            })?;
108        let query = Query::new(&language, spec.query_source).map_err(|e| ExtractError::Query {
109            language: spec.name,
110            message: e.to_string(),
111        })?;
112        let inline = spec
113            .inline
114            .map(|i| {
115                let language = (i.grammar)();
116                let mut parser = Parser::new();
117                parser
118                    .set_language(&language)
119                    .map_err(|e| (spec.name, e.to_string()))?;
120                let query = Query::new(&language, i.query_source)
121                    .map_err(|e| (spec.name, e.to_string()))?;
122                Ok((parser, query))
123            })
124            .transpose()
125            .map_err(
126                |(language, message): (&'static str, String)| ExtractError::Query {
127                    language,
128                    message,
129                },
130            )?;
131        Ok(Self {
132            spec,
133            parser,
134            query,
135            inline,
136            embedded_sql: None,
137        })
138    }
139
140    /// Extract facts from one file. `file` is the repo-relative path.
141    pub fn extract(&mut self, file: &str, source: &str) -> Result<FileFacts, ExtractError> {
142        let tree = self
143            .parser
144            .parse(source, None)
145            .ok_or_else(|| ExtractError::Parse(file.to_string()))?;
146        let root = tree.root_node();
147
148        let mut entries = Vec::new();
149        let mut collected = Collected::default();
150        collect(
151            &self.query,
152            self.spec,
153            root,
154            source,
155            &mut entries,
156            &mut collected,
157        );
158        // Secondary inline grammar (spec.inline): parse the container
159        // nodes' ranges of the same source, so capture spans are already
160        // file-absolute, and merge through the identical contract.
161        if let (Some((parser, query)), Some(ispec)) = (&mut self.inline, self.spec.inline) {
162            let ranges = container_ranges(root, ispec.container_kinds);
163            if !ranges.is_empty() {
164                // Both failure modes are broken invariants (container_ranges
165                // yields sorted non-overlapping ranges; the primary parse
166                // already succeeded) — swallowing them would silently drop
167                // this file's inline refs and let the graph assert "no
168                // links" without evidence. Fail loudly like the primary.
169                parser.set_included_ranges(&ranges).map_err(|e| {
170                    ExtractError::Parse(format!("{} (inline ranges: {e})", self.spec.name))
171                })?;
172                let inline_tree = parser
173                    .parse(source, None)
174                    .ok_or_else(|| ExtractError::Parse(format!("{} (inline)", self.spec.name)))?;
175                collect(
176                    query,
177                    self.spec,
178                    inline_tree.root_node(),
179                    source,
180                    &mut entries,
181                    &mut collected,
182                );
183            }
184        }
185        // Embedded SQL (`@sql` captures at known query sinks): re-parse
186        // each captured literal's range with the SQL grammar and merge its
187        // captures through the same contract .sql files use — edge-kind
188        // logic (reads/writes/creates/...) is shared, never duplicated.
189        // Literal-only by construction: the query patterns match string
190        // literals, so dynamically built SQL records nothing.
191        if !collected.sql_ranges.is_empty() {
192            if self.embedded_sql.is_none() {
193                let sql_spec = sql_language_spec();
194                let language = (sql_spec.grammar)();
195                let mut parser = Parser::new();
196                parser
197                    .set_language(&language)
198                    .map_err(|e| ExtractError::Query {
199                        language: sql_spec.name,
200                        message: e.to_string(),
201                    })?;
202                let query = Query::new(&language, sql_spec.query_source).map_err(|e| {
203                    ExtractError::Query {
204                        language: sql_spec.name,
205                        message: e.to_string(),
206                    }
207                })?;
208                self.embedded_sql = Some((parser, query));
209            }
210            let sql_spec = sql_language_spec();
211            let (parser, query) = self.embedded_sql.as_mut().expect("built above");
212            for range in std::mem::take(&mut collected.sql_ranges) {
213                // One range per parse: separate literals must never
214                // concatenate into a single SQL document.
215                if parser.set_included_ranges(&[range]).is_err() {
216                    continue;
217                }
218                let Some(tree) = parser.parse(source, None) else {
219                    continue;
220                };
221                let refs_before = collected.refs.len();
222                collect(
223                    query,
224                    sql_spec,
225                    tree.root_node(),
226                    source,
227                    &mut entries,
228                    &mut collected,
229                );
230                // Unparseable or fragmentary embedded SQL: record one
231                // conservative, never-binding reference (it surfaces in
232                // `sinter unresolved` at this site) rather than guessing a
233                // table. Gated on a statement keyword so a non-SQL string
234                // argument reaching a sink pattern stays silent.
235                if collected.refs.len() == refs_before && tree.root_node().has_error() {
236                    let text = &source[range.start_byte..range.end_byte];
237                    let head = text.split_whitespace().next().unwrap_or("");
238                    if SQL_STATEMENT_KEYWORDS
239                        .iter()
240                        .any(|k| head.eq_ignore_ascii_case(k))
241                    {
242                        let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
243                        collected.refs.push(RawRef {
244                            start: range.start_byte,
245                            end: range.end_byte,
246                            name: collapsed.chars().take(60).collect(),
247                            path: None,
248                            alias: None,
249                            relation: Relation::Uses,
250                        });
251                    }
252                }
253            }
254        }
255        entries.sort_by_key(|e| (e.start, usize::MAX - e.end));
256        // Explicit @doc captures override sibling-comment docs on the
257        // smallest definition containing them (Python docstrings). Several
258        // captures on one definition (markdown section blocks) are joined
259        // with blank lines in document order.
260        let mut doc_set = vec![false; entries.len()];
261        for (d_start, d_end, text) in &collected.docs {
262            let owner = entries
263                .iter_mut()
264                .enumerate()
265                .filter(|(_, e)| e.kind.is_some() && e.start <= *d_start && *d_end <= e.end)
266                .min_by_key(|(_, e)| e.end - e.start);
267            if let Some((i, entry)) = owner {
268                let cleaned: Vec<&str> = text.lines().map(str::trim).collect();
269                let trimmed = cleaned.join("\n");
270                let trimmed = trimmed.trim_matches('\n');
271                if trimmed.is_empty() {
272                    continue;
273                }
274                entry.doc = match entry.doc.take().filter(|_| doc_set[i]) {
275                    // Whole-section bodies can run long; keep the beginning
276                    // (what ask/show surface) and stop appending past the cap.
277                    Some(prev) if prev.len() >= DOC_CAP_BYTES => Some(prev),
278                    Some(prev) => Some(format!("{prev}\n\n{trimmed}")),
279                    None => Some(trimmed.to_string()),
280                };
281                doc_set[i] = true;
282            }
283        }
284        // Two patterns may claim the same node (e.g. `const f = () => ...`
285        // as variable and function): the more specific, non-variable kind
286        // wins; sort puts identical spans adjacent.
287        entries.dedup_by(|b, a| {
288            let same = a.start == b.start && a.end == b.end && a.name == b.name;
289            if same && a.kind == Some(SymbolKind::Variable) && b.kind.is_some() {
290                a.kind = b.kind;
291            }
292            same
293        });
294
295        let file_id = NodeId::new(file);
296        let mut nodes = vec![Node {
297            id: file_id.clone(),
298            kind: SymbolKind::File,
299            name: file.to_string(),
300            file: file.to_string(),
301            span: Span {
302                start: 0,
303                end: source.len().max(1) as u64,
304            },
305            signature: String::new(),
306            doc: None,
307        }];
308        let mut contains = Vec::new();
309
310        // Containment stack: (end, scope name, node id if a real definition).
311        let mut stack: Vec<(usize, String, Option<NodeId>)> = Vec::new();
312        // (start, end, id, kind) of each definition, for enclosure and
313        // declared-field owner lookup.
314        let mut def_spans: Vec<(usize, usize, NodeId, SymbolKind)> = Vec::new();
315
316        for entry in &entries {
317            while stack.last().is_some_and(|(end, _, _)| *end <= entry.start) {
318                stack.pop();
319            }
320            let mut path: Vec<&str> = stack.iter().map(|(_, name, _)| name.as_str()).collect();
321            if let Some(q) = &entry.qualifier {
322                path.push(q);
323            }
324            path.push(&entry.name);
325            let qualified = path.join("::");
326            // Children nest under the entry's qualified segment.
327            let scope_segment = entry
328                .qualifier
329                .as_ref()
330                .map_or(entry.name.clone(), |q| format!("{q}::{}", entry.name));
331
332            let id = if let Some(kind) = entry.kind {
333                let id = NodeId::new(format!("{file}#{qualified}@{}", entry.start));
334                let parent = stack
335                    .iter()
336                    .rev()
337                    .find_map(|(_, _, id)| id.clone())
338                    .unwrap_or_else(|| file_id.clone());
339                nodes.push(Node {
340                    id: id.clone(),
341                    kind,
342                    name: entry.name.clone(),
343                    file: file.to_string(),
344                    span: Span {
345                        start: entry.start as u64,
346                        end: entry.end as u64,
347                    },
348                    signature: entry.signature.clone(),
349                    doc: entry.doc.clone(),
350                });
351                contains.push(Edge {
352                    src: parent,
353                    dst: id.clone(),
354                    relation: Relation::Contains,
355                    evidence: Evidence::Structural,
356                    confidence: Evidence::Structural.confidence(),
357                    // Containment is structure, not a reference: no site.
358                    site: None,
359                });
360                def_spans.push((entry.start, entry.end, id.clone(), kind));
361                Some(id)
362            } else {
363                None
364            };
365            stack.push((entry.end, scope_segment, id));
366        }
367
368        // One span may be captured twice (a scoped type's name both with
369        // its path and bare): keep the path-bearing one, never both.
370        let mut refs = collected.refs;
371        refs.retain(|r| {
372            r.relation == Relation::Creates || !collected.def_name_spans.contains(&(r.start, r.end))
373        });
374        // Rust prelude/primitive names never name a corpus symbol unless
375        // this file defines one of the same name (then it may shadow).
376        if self.spec.name == "rust" {
377            refs.retain(|r| {
378                r.path.is_some()
379                    || !RUST_PRELUDE.contains(&r.name.as_str())
380                    || entries.iter().any(|e| e.name == r.name)
381            });
382        }
383        // More specific semantic captures win when query patterns overlap.
384        // SQL UPDATE targets, for example, are both a generic relation and a
385        // write target in the parse tree; retaining `writes` prevents the
386        // generic `reads` capture from laundering a mutation into a read.
387        let relation_priority = |relation: Relation| match relation {
388            Relation::Writes => 0,
389            Relation::Reads => 1,
390            _ => 2,
391        };
392        refs.sort_by_key(|r| {
393            (
394                r.start,
395                r.end,
396                r.path.is_none(),
397                relation_priority(r.relation),
398            )
399        });
400        refs.dedup_by_key(|r| (r.start, r.end));
401        let references = refs
402            .into_iter()
403            .map(|r| {
404                // A CREATE reference names the definition it encloses. Its
405                // owner is the migration/query file, not the new object;
406                // otherwise resolution would discard the resulting self-edge.
407                let enclosing = (r.relation != Relation::Creates)
408                    .then(|| {
409                        def_spans
410                            .iter()
411                            .filter(|(s, e, _, _)| *s <= r.start && r.end <= *e)
412                            .min_by_key(|(s, e, _, _)| e - s)
413                            .map(|(_, _, id, _)| id.clone())
414                    })
415                    .flatten();
416                Reference {
417                    file: file.to_string(),
418                    name: r.name,
419                    path: r.path,
420                    relation: r.relation,
421                    span: Span {
422                        start: r.start as u64,
423                        end: r.end as u64,
424                    },
425                    enclosing,
426                    alias: r.alias,
427                }
428            })
429            .collect();
430
431        // A local shadows from its introduction to the end of the innermost
432        // definition containing it (file end at top level). A "local" whose
433        // span is an import alias IS the import binding, not a shadow.
434        let alias_spans = collected.alias_spans;
435        let embeds = collected
436            .embeds
437            .iter()
438            .filter_map(|(start, end, type_name)| {
439                let owner = def_spans
440                    .iter()
441                    .filter(|(s, e, _, _)| s <= start && end <= e)
442                    .min_by_key(|(s, e, _, _)| e - s)
443                    .map(|(_, _, id, _)| id.clone())?;
444                Some(sinter_core::Embed {
445                    owner,
446                    type_name: type_name.clone(),
447                })
448            })
449            .collect();
450        let mut raw_locals = collected
451            .locals
452            .into_iter()
453            .filter(|l| !alias_spans.contains(&(l.start, l.end)))
454            .collect::<Vec<_>>();
455        // Typed and untyped query patterns can match the same binding.
456        // Keep exactly one, preferring the typed capture.
457        raw_locals.sort_by_key(|l| (l.start, l.end, l.name.clone(), l.type_name.is_none()));
458        raw_locals.dedup_by(|b, a| a.start == b.start && a.end == b.end && a.name == b.name);
459        let locals = raw_locals
460            .into_iter()
461            .map(|l| {
462                let scope_end = def_spans
463                    .iter()
464                    .filter(|(s, e, _, _)| *s <= l.start && l.end <= *e)
465                    .min_by_key(|(s, e, _, _)| e - s)
466                    .map_or(source.len() as u64, |(_, e, _, _)| *e as u64);
467                sinter_core::LocalBinding {
468                    file: file.to_string(),
469                    name: l.name,
470                    span: Span {
471                        start: l.start as u64,
472                        end: l.end as u64,
473                    },
474                    scope_end,
475                    type_name: l.type_name,
476                }
477            })
478            .collect();
479
480        let fields = collected
481            .fields
482            .iter()
483            .filter_map(|(start, end, name, type_name)| {
484                let owner = def_spans
485                    .iter()
486                    .filter(|(s, e, _, kind)| {
487                        *s <= *start
488                            && *end <= *e
489                            && matches!(
490                                kind,
491                                SymbolKind::Struct
492                                    | SymbolKind::Class
493                                    | SymbolKind::Trait
494                                    | SymbolKind::Interface
495                            )
496                    })
497                    .min_by_key(|(s, e, _, _)| e - s)
498                    .map(|(_, _, id, _)| id.clone())?;
499                Some(FieldBinding {
500                    owner,
501                    name: name.clone(),
502                    type_name: type_name.clone(),
503                })
504            })
505            .collect();
506
507        let trait_impls = collected
508            .trait_impls
509            .iter()
510            .map(|(start, end, trait_name)| sinter_core::TraitImpl {
511                file: file.to_string(),
512                trait_name: trait_name.clone(),
513                span: Span {
514                    start: *start as u64,
515                    end: *end as u64,
516                },
517            })
518            .collect();
519        let scopes = crate::scope::node_scopes(self.spec.name, source, &nodes);
520        let body_terms = crate::body_terms::body_terms(source, &nodes, &scopes);
521        Ok(FileFacts {
522            file: file.to_string(),
523            content_hash: blake3::hash(source.as_bytes()).to_hex().to_string(),
524            has_syntax_errors: root.has_error(),
525            nodes,
526            contains,
527            references,
528            locals,
529            fields,
530            embeds,
531            trait_impls,
532            scopes,
533            body_terms,
534        })
535    }
536}
537
538/// The registered SQL language spec — grammar and query for embedded-SQL
539/// (`@sql`) ranges. The row is data in LANGUAGES; panicking on absence is
540/// a broken build, not a runtime condition.
541fn sql_language_spec() -> &'static LanguageSpec {
542    crate::language::LANGUAGES
543        .iter()
544        .find(|s| s.name == "sql")
545        .expect("sql language registered in LANGUAGES")
546}
547
548/// Statement keywords gating the fragmentary-embedded-SQL marker: text at
549/// a query sink that starts with one of these but yields no SQL facts is
550/// recorded as a never-binding reference instead of being dropped.
551const SQL_STATEMENT_KEYWORDS: &[&str] = &[
552    "SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE", "ALTER", "DROP", "TRUNCATE", "MERGE",
553    "REPLACE",
554];
555
556/// Bare Rust names that always come from the prelude or are primitive
557/// types: a reference to one is noise unless the file shadows it.
558const RUST_PRELUDE: &[&str] = &[
559    "Ok", "Err", "Some", "None", "Vec", "String", "Box", "Option", "Result", "Self", "bool",
560    "char", "str", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64", "i128",
561    "isize", "f32", "f64",
562];
563
564/// Byte ranges of every node of the given kinds, in document order —
565/// the included-range input for a secondary inline parse. Matched nodes
566/// are not descended into, so ranges never overlap.
567fn container_ranges(root: TsNode<'_>, kinds: &[&str]) -> Vec<tree_sitter::Range> {
568    let mut out = Vec::new();
569    let mut stack = vec![root];
570    while let Some(node) = stack.pop() {
571        if kinds.contains(&node.kind()) {
572            out.push(node.range());
573        } else {
574            for i in (0..node.child_count()).rev() {
575                stack.extend(node.child(i));
576            }
577        }
578    }
579    out.sort_by_key(|r| r.start_byte);
580    out
581}
582
583/// Run one query over one tree; group captures per match by the universal
584/// contract, appending to `entries`/`out` (called once per grammar).
585fn collect(
586    query: &Query,
587    spec: &LanguageSpec,
588    root: TsNode<'_>,
589    source: &str,
590    entries: &mut Vec<RawEntry>,
591    out: &mut Collected,
592) {
593    {
594        let mut cursor = QueryCursor::new();
595        let mut matches = cursor.matches(query, root, source.as_bytes());
596        while let Some(m) = matches.next() {
597            let mut def: Option<(TsNode, SymbolKind)> = None;
598            let mut scope: Option<TsNode> = None;
599            let mut name: Option<TsNode> = None;
600            let mut qualifier: Option<TsNode> = None;
601            let mut reference: Option<(TsNode, Relation)> = None;
602            let mut refpath: Option<TsNode> = None;
603            let mut import_path: Option<TsNode> = None;
604            let mut import_module: Option<TsNode> = None;
605            let mut import_name: Option<TsNode> = None;
606            let mut import_alias: Option<TsNode> = None;
607            let mut import_star = false;
608            let mut match_locals: Vec<TsNode> = Vec::new();
609            let mut local_type: Option<TsNode> = None;
610            let mut field_name: Option<TsNode> = None;
611            let mut field_type: Option<TsNode> = None;
612            let mut trait_name: Option<TsNode> = None;
613            let mut trait_impl: Option<TsNode> = None;
614            for cap in m.captures {
615                let cap_name = &query.capture_names()[cap.index as usize];
616                if let Some(kind_str) = cap_name.strip_prefix("def.") {
617                    if let Some(kind) = SymbolKind::from_str_opt(kind_str) {
618                        def = Some((cap.node, kind));
619                    }
620                } else if let Some(rel) = cap_name.strip_prefix("ref.") {
621                    let relation = match rel {
622                        "use" => Relation::Uses,
623                        "read" => Relation::Reads,
624                        "write" => Relation::Writes,
625                        "create" => Relation::Creates,
626                        "alter" => Relation::Alters,
627                        "drop" => Relation::Drops,
628                        _ => Relation::Calls,
629                    };
630                    reference = Some((cap.node, relation));
631                } else {
632                    match *cap_name {
633                        "scope" => scope = Some(cap.node),
634                        "name" => name = Some(cap.node),
635                        "qualifier" => qualifier = Some(cap.node),
636                        "refpath" => refpath = Some(cap.node),
637                        "import" => import_path = Some(cap.node),
638                        "import.module" => import_module = Some(cap.node),
639                        "import.name" => import_name = Some(cap.node),
640                        "import.alias" => import_alias = Some(cap.node),
641                        "import.star" => import_star = true,
642                        "local" => match_locals.push(cap.node),
643                        "local.type" => local_type = Some(cap.node),
644                        "field.name" => field_name = Some(cap.node),
645                        "field.type" => field_type = Some(cap.node),
646                        "trait" => trait_name = Some(cap.node),
647                        "trait.impl" => trait_impl = Some(cap.node),
648                        "doc" => out.docs.push((
649                            cap.node.start_byte(),
650                            cap.node.end_byte(),
651                            text(cap.node, source).to_string(),
652                        )),
653                        "sql" => out.sql_ranges.push(cap.node.range()),
654                        "embed" => out.embeds.push((
655                            cap.node.start_byte(),
656                            cap.node.end_byte(),
657                            text(cap.node, source).to_string(),
658                        )),
659                        _ => {}
660                    }
661                }
662            }
663            let sep = spec.path_separators.first().copied().unwrap_or(".");
664            if let (Some(t), Some(block)) = (trait_name, trait_impl) {
665                out.trait_impls.push((
666                    block.start_byte(),
667                    block.end_byte(),
668                    text(t, source).to_string(),
669                ));
670            }
671            if let (Some(name), Some(ty)) = (field_name, field_type) {
672                out.fields.push((
673                    name.start_byte(),
674                    ty.end_byte(),
675                    text(name, source).to_string(),
676                    text(ty, source).to_string(),
677                ));
678            }
679            if let Some(a) = import_alias {
680                out.alias_spans.push((a.start_byte(), a.end_byte()));
681            }
682            let alias = import_alias.map(|a| text(a, source).to_string());
683            for l in &match_locals {
684                out.locals.push(RawLocal {
685                    start: l.start_byte(),
686                    end: l.end_byte(),
687                    name: text(*l, source).to_string(),
688                    type_name: local_type.map(|t| text(t, source).to_string()),
689                });
690            }
691            if let Some(path_node) = import_path {
692                // Whole-path import (`use a::b`, `import "pkg"`), possibly
693                // with an alias, Go's dot form, or glob semantics
694                // (`@import.star` alongside: bash `source` binds every name).
695                out.refs.push(RawRef {
696                    start: path_node.start_byte(),
697                    end: path_node.end_byte(),
698                    name: text(path_node, source)
699                        .trim_matches(['"', '\'', '`'])
700                        .to_string(),
701                    path: None,
702                    alias: alias.or_else(|| import_star.then(|| "*".to_string())),
703                    relation: Relation::Imports,
704                });
705            } else if let (Some(module), Some(item)) = (import_module, import_name) {
706                // From-style import: module and item joined so the import
707                // binds the item itself. Alias renames the local binding.
708                let module_text = text(module, source).trim_matches(['"', '\'', '`']);
709                out.refs.push(RawRef {
710                    start: module.start_byte().min(item.start_byte()),
711                    end: item.end_byte().max(module.end_byte()),
712                    name: format!("{module_text}{sep}{}", text(item, source)),
713                    path: None,
714                    alias,
715                    relation: Relation::Imports,
716                });
717            } else if let (Some(module), true) = (import_module, import_star) {
718                // Glob import: every top-level name of the module is bound.
719                let module_text = text(module, source).trim_matches(['"', '\'', '`']);
720                out.refs.push(RawRef {
721                    start: module.start_byte(),
722                    end: module.end_byte(),
723                    name: format!("{module_text}{sep}*"),
724                    path: None,
725                    alias: Some("*".to_string()),
726                    relation: Relation::Imports,
727                });
728            }
729            if let Some((node, relation)) = reference {
730                out.refs.push(RawRef {
731                    start: node.start_byte(),
732                    end: node.end_byte(),
733                    name: text(node, source).to_string(),
734                    path: refpath.map(|p| qualified_path(p, node, source)),
735                    alias: None,
736                    relation,
737                });
738            }
739            let container = def.map(|(n, _)| n).or(scope);
740            if let (Some(container), Some(name_node)) = (container, name) {
741                if def.is_some() {
742                    out.def_name_spans
743                        .push((name_node.start_byte(), name_node.end_byte()));
744                }
745                entries.push(RawEntry {
746                    start: container.start_byte(),
747                    end: container.end_byte(),
748                    name: text(name_node, source).to_string(),
749                    kind: def.map(|(_, k)| k),
750                    qualifier: qualifier.map(|q| text(q, source).to_string()),
751                    signature: signature(container, source),
752                    doc: doc_comment(container, source, spec.comment_kinds, spec.doc_skip_kinds),
753                });
754            }
755        }
756    }
757}
758
759/// Path text for a qualified reference: the immediate receiver joined to
760/// the referenced name by the separator written between them. Using the
761/// receiver child rather than the whole `refpath` text keeps a method
762/// chain (`a.b().c()`) from smearing the entire chain into the path of
763/// its tail, and drops the whitespace/comments around the separator.
764fn qualified_path(refpath: TsNode<'_>, name: TsNode<'_>, source: &str) -> String {
765    let receiver = refpath
766        .named_child(0)
767        .filter(|c| refpath.id() != name.id() && c.end_byte() <= name.start_byte());
768    let Some(receiver) = receiver else {
769        return text(refpath, source).to_string();
770    };
771    let sep = source[receiver.end_byte()..name.start_byte()].trim();
772    format!("{}{sep}{}", text(receiver, source), text(name, source))
773}
774
775fn text<'a>(node: TsNode<'_>, source: &'a str) -> &'a str {
776    &source[node.start_byte()..node.end_byte()]
777}
778
779/// Declaration text up to the body. Brace languages cut at the first `{`;
780/// a first line ending in `:` (Python-style) is the whole signature.
781fn signature(node: TsNode<'_>, source: &str) -> String {
782    let t = text(node, source);
783    let first_line = t.lines().next().unwrap_or(t);
784    let head = if first_line.trim_end().ends_with(':') || first_line.contains('{') {
785        first_line.split('{').next().unwrap_or(first_line)
786    } else {
787        let up_to_brace = t.split('{').next().unwrap_or(t);
788        if up_to_brace.len() == t.len() {
789            first_line
790        } else {
791            up_to_brace
792        }
793    };
794    head.split_whitespace().collect::<Vec<_>>().join(" ")
795}
796
797/// Contiguous comment siblings immediately above the definition (or its
798/// parent declaration), stripped of comment markers. Generic across
799/// languages: comment node kinds come from the spec.
800fn doc_comment(
801    node: TsNode<'_>,
802    source: &str,
803    comment_kinds: &[&str],
804    skip_kinds: &[&str],
805) -> Option<String> {
806    let comments = preceding_comments(node, comment_kinds, skip_kinds).or_else(|| {
807        node.parent()
808            .and_then(|p| preceding_comments(p, comment_kinds, skip_kinds))
809    })?;
810    let mut lines = Vec::new();
811    for c in comments {
812        for line in text(c, source).lines() {
813            let mut l = line.trim();
814            for marker in ["///", "//!", "//", "/**", "/*", "*/", "--"] {
815                if let Some(stripped) = l.strip_prefix(marker) {
816                    l = stripped;
817                    break;
818                }
819            }
820            // Block-comment continuation: a leading `*` (Javadoc/C-style
821            // interior line) is decoration — but `**bold**` is markdown.
822            if let Some(rest) = l.strip_prefix('*')
823                && !l.starts_with("**")
824            {
825                l = rest;
826            }
827            l = l.strip_suffix("*/").unwrap_or(l);
828            lines.push(l.trim());
829        }
830    }
831    while lines.first().is_some_and(|l| l.is_empty()) {
832        lines.remove(0);
833    }
834    while lines.last().is_some_and(|l| l.is_empty()) {
835        lines.pop();
836    }
837    if lines.is_empty() {
838        None
839    } else {
840        Some(lines.join("\n"))
841    }
842}
843
844fn preceding_comments<'t>(
845    node: TsNode<'t>,
846    comment_kinds: &[&str],
847    skip_kinds: &[&str],
848) -> Option<Vec<TsNode<'t>>> {
849    let mut comments = Vec::new();
850    let mut cur = node.prev_named_sibling();
851    let mut skips = 0;
852    while let Some(sib) = cur {
853        if !comment_kinds.contains(&sib.kind()) {
854            // Step over decorator-style macro lines (UCLASS, UPROPERTY)
855            // that sit between a definition and its doc comment.
856            if skips < 2 && comments.is_empty() && skip_kinds.contains(&sib.kind()) {
857                skips += 1;
858                cur = sib.prev_named_sibling();
859                continue;
860            }
861            break;
862        }
863        comments.push(sib);
864        cur = sib.prev_named_sibling();
865    }
866    comments.reverse();
867    if comments.is_empty() {
868        None
869    } else {
870        Some(comments)
871    }
872}