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