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                    extra_sites: Vec::new(),
360                    sites_total: 0,
361                });
362                def_spans.push((entry.start, entry.end, id.clone(), kind));
363                Some(id)
364            } else {
365                None
366            };
367            stack.push((entry.end, scope_segment, id));
368        }
369
370        // One span may be captured twice (a scoped type's name both with
371        // its path and bare): keep the path-bearing one, never both.
372        let mut refs = collected.refs;
373        refs.retain(|r| {
374            r.relation == Relation::Creates || !collected.def_name_spans.contains(&(r.start, r.end))
375        });
376        // Rust prelude/primitive names never name a corpus symbol unless
377        // this file defines one of the same name (then it may shadow).
378        if self.spec.name == "rust" {
379            refs.retain(|r| {
380                r.path.is_some()
381                    || !RUST_PRELUDE.contains(&r.name.as_str())
382                    || entries.iter().any(|e| e.name == r.name)
383            });
384        }
385        // More specific semantic captures win when query patterns overlap.
386        // SQL UPDATE targets, for example, are both a generic relation and a
387        // write target in the parse tree; retaining `writes` prevents the
388        // generic `reads` capture from laundering a mutation into a read.
389        let relation_priority = |relation: Relation| match relation {
390            Relation::Writes => 0,
391            Relation::Reads => 1,
392            _ => 2,
393        };
394        refs.sort_by_key(|r| {
395            (
396                r.start,
397                r.end,
398                r.path.is_none(),
399                relation_priority(r.relation),
400            )
401        });
402        refs.dedup_by_key(|r| (r.start, r.end));
403        let references = refs
404            .into_iter()
405            .map(|r| {
406                // A CREATE reference names the definition it encloses. Its
407                // owner is the migration/query file, not the new object;
408                // otherwise resolution would discard the resulting self-edge.
409                let enclosing = (r.relation != Relation::Creates)
410                    .then(|| {
411                        def_spans
412                            .iter()
413                            .filter(|(s, e, _, _)| *s <= r.start && r.end <= *e)
414                            .min_by_key(|(s, e, _, _)| e - s)
415                            .map(|(_, _, id, _)| id.clone())
416                    })
417                    .flatten();
418                Reference {
419                    file: file.to_string(),
420                    name: r.name,
421                    path: r.path,
422                    relation: r.relation,
423                    span: Span {
424                        start: r.start as u64,
425                        end: r.end as u64,
426                    },
427                    enclosing,
428                    alias: r.alias,
429                }
430            })
431            .collect();
432
433        // A local shadows from its introduction to the end of the innermost
434        // definition containing it (file end at top level). A "local" whose
435        // span is an import alias IS the import binding, not a shadow.
436        let alias_spans = collected.alias_spans;
437        let embeds = collected
438            .embeds
439            .iter()
440            .filter_map(|(start, end, type_name)| {
441                let owner = def_spans
442                    .iter()
443                    .filter(|(s, e, _, _)| s <= start && end <= e)
444                    .min_by_key(|(s, e, _, _)| e - s)
445                    .map(|(_, _, id, _)| id.clone())?;
446                Some(sinter_core::Embed {
447                    owner,
448                    type_name: type_name.clone(),
449                })
450            })
451            .collect();
452        let mut raw_locals = collected
453            .locals
454            .into_iter()
455            .filter(|l| !alias_spans.contains(&(l.start, l.end)))
456            .collect::<Vec<_>>();
457        // Typed and untyped query patterns can match the same binding.
458        // Keep exactly one, preferring the typed capture.
459        raw_locals.sort_by_key(|l| (l.start, l.end, l.name.clone(), l.type_name.is_none()));
460        raw_locals.dedup_by(|b, a| a.start == b.start && a.end == b.end && a.name == b.name);
461        let locals = raw_locals
462            .into_iter()
463            .map(|l| {
464                let scope_end = def_spans
465                    .iter()
466                    .filter(|(s, e, _, _)| *s <= l.start && l.end <= *e)
467                    .min_by_key(|(s, e, _, _)| e - s)
468                    .map_or(source.len() as u64, |(_, e, _, _)| *e as u64);
469                sinter_core::LocalBinding {
470                    file: file.to_string(),
471                    name: l.name,
472                    span: Span {
473                        start: l.start as u64,
474                        end: l.end as u64,
475                    },
476                    scope_end,
477                    type_name: l.type_name,
478                }
479            })
480            .collect();
481
482        let fields = collected
483            .fields
484            .iter()
485            .filter_map(|(start, end, name, type_name)| {
486                let owner = def_spans
487                    .iter()
488                    .filter(|(s, e, _, kind)| {
489                        *s <= *start
490                            && *end <= *e
491                            && matches!(
492                                kind,
493                                SymbolKind::Struct
494                                    | SymbolKind::Class
495                                    | SymbolKind::Trait
496                                    | SymbolKind::Interface
497                            )
498                    })
499                    .min_by_key(|(s, e, _, _)| e - s)
500                    .map(|(_, _, id, _)| id.clone())?;
501                Some(FieldBinding {
502                    owner,
503                    name: name.clone(),
504                    type_name: type_name.clone(),
505                })
506            })
507            .collect();
508
509        let trait_impls = collected
510            .trait_impls
511            .iter()
512            .map(|(start, end, trait_name)| sinter_core::TraitImpl {
513                file: file.to_string(),
514                trait_name: trait_name.clone(),
515                span: Span {
516                    start: *start as u64,
517                    end: *end as u64,
518                },
519            })
520            .collect();
521        let scopes = crate::scope::node_scopes(self.spec.name, source, &nodes);
522        let body_terms = crate::body_terms::body_terms(source, &nodes, &scopes);
523        Ok(FileFacts {
524            file: file.to_string(),
525            content_hash: blake3::hash(source.as_bytes()).to_hex().to_string(),
526            has_syntax_errors: root.has_error(),
527            nodes,
528            contains,
529            references,
530            locals,
531            fields,
532            embeds,
533            trait_impls,
534            scopes,
535            body_terms,
536        })
537    }
538}
539
540/// The registered SQL language spec — grammar and query for embedded-SQL
541/// (`@sql`) ranges. The row is data in LANGUAGES; panicking on absence is
542/// a broken build, not a runtime condition.
543fn sql_language_spec() -> &'static LanguageSpec {
544    crate::language::LANGUAGES
545        .iter()
546        .find(|s| s.name == "sql")
547        .expect("sql language registered in LANGUAGES")
548}
549
550/// Statement keywords gating the fragmentary-embedded-SQL marker: text at
551/// a query sink that starts with one of these but yields no SQL facts is
552/// recorded as a never-binding reference instead of being dropped.
553const SQL_STATEMENT_KEYWORDS: &[&str] = &[
554    "SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE", "ALTER", "DROP", "TRUNCATE", "MERGE",
555    "REPLACE",
556];
557
558/// Bare Rust names that always come from the prelude or are primitive
559/// types: a reference to one is noise unless the file shadows it.
560const RUST_PRELUDE: &[&str] = &[
561    "Ok", "Err", "Some", "None", "Vec", "String", "Box", "Option", "Result", "Self", "bool",
562    "char", "str", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64", "i128",
563    "isize", "f32", "f64",
564];
565
566/// Byte ranges of every node of the given kinds, in document order —
567/// the included-range input for a secondary inline parse. Matched nodes
568/// are not descended into, so ranges never overlap.
569fn container_ranges(root: TsNode<'_>, kinds: &[&str]) -> Vec<tree_sitter::Range> {
570    let mut out = Vec::new();
571    let mut stack = vec![root];
572    while let Some(node) = stack.pop() {
573        if kinds.contains(&node.kind()) {
574            out.push(node.range());
575        } else {
576            for i in (0..node.child_count()).rev() {
577                stack.extend(node.child(i));
578            }
579        }
580    }
581    out.sort_by_key(|r| r.start_byte);
582    out
583}
584
585/// Run one query over one tree; group captures per match by the universal
586/// contract, appending to `entries`/`out` (called once per grammar).
587fn collect(
588    query: &Query,
589    spec: &LanguageSpec,
590    root: TsNode<'_>,
591    source: &str,
592    entries: &mut Vec<RawEntry>,
593    out: &mut Collected,
594) {
595    {
596        let mut cursor = QueryCursor::new();
597        let mut matches = cursor.matches(query, root, source.as_bytes());
598        while let Some(m) = matches.next() {
599            let mut def: Option<(TsNode, SymbolKind)> = None;
600            let mut scope: Option<TsNode> = None;
601            let mut name: Option<TsNode> = None;
602            let mut qualifier: Option<TsNode> = None;
603            let mut reference: Option<(TsNode, Relation)> = None;
604            let mut refpath: Option<TsNode> = None;
605            let mut import_path: Option<TsNode> = None;
606            let mut import_module: Option<TsNode> = None;
607            let mut import_name: Option<TsNode> = None;
608            let mut import_alias: Option<TsNode> = None;
609            let mut import_star = false;
610            let mut match_locals: Vec<TsNode> = Vec::new();
611            let mut local_type: Option<TsNode> = None;
612            let mut field_name: Option<TsNode> = None;
613            let mut field_type: Option<TsNode> = None;
614            let mut trait_name: Option<TsNode> = None;
615            let mut trait_impl: Option<TsNode> = None;
616            for cap in m.captures {
617                let cap_name = &query.capture_names()[cap.index as usize];
618                if let Some(kind_str) = cap_name.strip_prefix("def.") {
619                    if let Some(kind) = SymbolKind::from_str_opt(kind_str) {
620                        def = Some((cap.node, kind));
621                    }
622                } else if let Some(rel) = cap_name.strip_prefix("ref.") {
623                    let relation = match rel {
624                        "use" => Relation::Uses,
625                        "read" => Relation::Reads,
626                        "write" => Relation::Writes,
627                        "create" => Relation::Creates,
628                        "alter" => Relation::Alters,
629                        "drop" => Relation::Drops,
630                        _ => Relation::Calls,
631                    };
632                    reference = Some((cap.node, relation));
633                } else {
634                    match *cap_name {
635                        "scope" => scope = Some(cap.node),
636                        "name" => name = Some(cap.node),
637                        "qualifier" => qualifier = Some(cap.node),
638                        "refpath" => refpath = Some(cap.node),
639                        "import" => import_path = Some(cap.node),
640                        "import.module" => import_module = Some(cap.node),
641                        "import.name" => import_name = Some(cap.node),
642                        "import.alias" => import_alias = Some(cap.node),
643                        "import.star" => import_star = true,
644                        "local" => match_locals.push(cap.node),
645                        "local.type" => local_type = Some(cap.node),
646                        "field.name" => field_name = Some(cap.node),
647                        "field.type" => field_type = Some(cap.node),
648                        "trait" => trait_name = Some(cap.node),
649                        "trait.impl" => trait_impl = Some(cap.node),
650                        "doc" => out.docs.push((
651                            cap.node.start_byte(),
652                            cap.node.end_byte(),
653                            text(cap.node, source).to_string(),
654                        )),
655                        "sql" => out.sql_ranges.push(cap.node.range()),
656                        "embed" => out.embeds.push((
657                            cap.node.start_byte(),
658                            cap.node.end_byte(),
659                            text(cap.node, source).to_string(),
660                        )),
661                        _ => {}
662                    }
663                }
664            }
665            let sep = spec.path_separators.first().copied().unwrap_or(".");
666            if let (Some(t), Some(block)) = (trait_name, trait_impl) {
667                out.trait_impls.push((
668                    block.start_byte(),
669                    block.end_byte(),
670                    text(t, source).to_string(),
671                ));
672            }
673            if let (Some(name), Some(ty)) = (field_name, field_type) {
674                out.fields.push((
675                    name.start_byte(),
676                    ty.end_byte(),
677                    text(name, source).to_string(),
678                    text(ty, source).to_string(),
679                ));
680            }
681            if let Some(a) = import_alias {
682                out.alias_spans.push((a.start_byte(), a.end_byte()));
683            }
684            let alias = import_alias.map(|a| text(a, source).to_string());
685            for l in &match_locals {
686                out.locals.push(RawLocal {
687                    start: l.start_byte(),
688                    end: l.end_byte(),
689                    name: text(*l, source).to_string(),
690                    type_name: local_type.map(|t| text(t, source).to_string()),
691                });
692            }
693            if let Some(path_node) = import_path {
694                // Whole-path import (`use a::b`, `import "pkg"`), possibly
695                // with an alias, Go's dot form, or glob semantics
696                // (`@import.star` alongside: bash `source` binds every name).
697                out.refs.push(RawRef {
698                    start: path_node.start_byte(),
699                    end: path_node.end_byte(),
700                    name: text(path_node, source)
701                        .trim_matches(['"', '\'', '`'])
702                        .to_string(),
703                    path: None,
704                    alias: alias.or_else(|| import_star.then(|| "*".to_string())),
705                    relation: Relation::Imports,
706                });
707            } else if let (Some(module), Some(item)) = (import_module, import_name) {
708                // From-style import: module and item joined so the import
709                // binds the item itself. Alias renames the local binding.
710                let module_text = text(module, source).trim_matches(['"', '\'', '`']);
711                out.refs.push(RawRef {
712                    start: module.start_byte().min(item.start_byte()),
713                    end: item.end_byte().max(module.end_byte()),
714                    name: format!("{module_text}{sep}{}", text(item, source)),
715                    path: None,
716                    alias,
717                    relation: Relation::Imports,
718                });
719            } else if let (Some(module), true) = (import_module, import_star) {
720                // Glob import: every top-level name of the module is bound.
721                let module_text = text(module, source).trim_matches(['"', '\'', '`']);
722                out.refs.push(RawRef {
723                    start: module.start_byte(),
724                    end: module.end_byte(),
725                    name: format!("{module_text}{sep}*"),
726                    path: None,
727                    alias: Some("*".to_string()),
728                    relation: Relation::Imports,
729                });
730            }
731            if let Some((node, relation)) = reference {
732                out.refs.push(RawRef {
733                    start: node.start_byte(),
734                    end: node.end_byte(),
735                    name: text(node, source).to_string(),
736                    path: refpath.map(|p| qualified_path(p, node, source)),
737                    alias: None,
738                    relation,
739                });
740            }
741            let container = def.map(|(n, _)| n).or(scope);
742            if let (Some(container), Some(name_node)) = (container, name) {
743                if def.is_some() {
744                    out.def_name_spans
745                        .push((name_node.start_byte(), name_node.end_byte()));
746                }
747                entries.push(RawEntry {
748                    start: container.start_byte(),
749                    end: container.end_byte(),
750                    name: text(name_node, source).to_string(),
751                    kind: def.map(|(_, k)| k),
752                    qualifier: qualifier.map(|q| text(q, source).to_string()),
753                    signature: signature(container, source),
754                    doc: doc_comment(container, source, spec.comment_kinds, spec.doc_skip_kinds),
755                });
756            }
757        }
758    }
759}
760
761/// Path text for a qualified reference: the immediate receiver joined to
762/// the referenced name by the separator written between them. Using the
763/// receiver child rather than the whole `refpath` text keeps a method
764/// chain (`a.b().c()`) from smearing the entire chain into the path of
765/// its tail, and drops the whitespace/comments around the separator.
766fn qualified_path(refpath: TsNode<'_>, name: TsNode<'_>, source: &str) -> String {
767    let receiver = refpath
768        .named_child(0)
769        .filter(|c| refpath.id() != name.id() && c.end_byte() <= name.start_byte());
770    let Some(receiver) = receiver else {
771        return text(refpath, source).to_string();
772    };
773    let sep = source[receiver.end_byte()..name.start_byte()].trim();
774    format!("{}{sep}{}", text(receiver, source), text(name, source))
775}
776
777fn text<'a>(node: TsNode<'_>, source: &'a str) -> &'a str {
778    &source[node.start_byte()..node.end_byte()]
779}
780
781/// Declaration text up to the body. Brace languages cut at the first `{`;
782/// a first line ending in `:` (Python-style) is the whole signature.
783fn signature(node: TsNode<'_>, source: &str) -> String {
784    let t = text(node, source);
785    let first_line = t.lines().next().unwrap_or(t);
786    let head = if first_line.trim_end().ends_with(':') || first_line.contains('{') {
787        first_line.split('{').next().unwrap_or(first_line)
788    } else {
789        let up_to_brace = t.split('{').next().unwrap_or(t);
790        if up_to_brace.len() == t.len() {
791            first_line
792        } else {
793            up_to_brace
794        }
795    };
796    head.split_whitespace().collect::<Vec<_>>().join(" ")
797}
798
799/// Contiguous comment siblings immediately above the definition (or its
800/// parent declaration), stripped of comment markers. Generic across
801/// languages: comment node kinds come from the spec.
802fn doc_comment(
803    node: TsNode<'_>,
804    source: &str,
805    comment_kinds: &[&str],
806    skip_kinds: &[&str],
807) -> Option<String> {
808    let comments = preceding_comments(node, comment_kinds, skip_kinds).or_else(|| {
809        node.parent()
810            .and_then(|p| preceding_comments(p, comment_kinds, skip_kinds))
811    })?;
812    let mut lines = Vec::new();
813    for c in comments {
814        for line in text(c, source).lines() {
815            let mut l = line.trim();
816            for marker in ["///", "//!", "//", "/**", "/*", "*/", "--"] {
817                if let Some(stripped) = l.strip_prefix(marker) {
818                    l = stripped;
819                    break;
820                }
821            }
822            // Block-comment continuation: a leading `*` (Javadoc/C-style
823            // interior line) is decoration — but `**bold**` is markdown.
824            if let Some(rest) = l.strip_prefix('*')
825                && !l.starts_with("**")
826            {
827                l = rest;
828            }
829            l = l.strip_suffix("*/").unwrap_or(l);
830            lines.push(l.trim());
831        }
832    }
833    while lines.first().is_some_and(|l| l.is_empty()) {
834        lines.remove(0);
835    }
836    while lines.last().is_some_and(|l| l.is_empty()) {
837        lines.pop();
838    }
839    if lines.is_empty() {
840        None
841    } else {
842        Some(lines.join("\n"))
843    }
844}
845
846fn preceding_comments<'t>(
847    node: TsNode<'t>,
848    comment_kinds: &[&str],
849    skip_kinds: &[&str],
850) -> Option<Vec<TsNode<'t>>> {
851    let mut comments = Vec::new();
852    let mut cur = node.prev_named_sibling();
853    let mut skips = 0;
854    while let Some(sib) = cur {
855        if !comment_kinds.contains(&sib.kind()) {
856            // Step over decorator-style macro lines (UCLASS, UPROPERTY)
857            // that sit between a definition and its doc comment.
858            if skips < 2 && comments.is_empty() && skip_kinds.contains(&sib.kind()) {
859                skips += 1;
860                cur = sib.prev_named_sibling();
861                continue;
862            }
863            break;
864        }
865        comments.push(sib);
866        cur = sib.prev_named_sibling();
867    }
868    comments.reverse();
869    if comments.is_empty() {
870        None
871    } else {
872        Some(comments)
873    }
874}