Skip to main content

sinter_extract/
extract.rs

1use sinter_core::{Edge, Evidence, Node, NodeId, Reference, Relation, Span, SymbolKind};
2use streaming_iterator::StreamingIterator;
3use tree_sitter::{Node as TsNode, Parser, Query, QueryCursor};
4
5use sinter_core::FileFacts;
6
7use crate::language::LanguageSpec;
8
9#[derive(Debug, thiserror::Error)]
10pub enum ExtractError {
11    #[error("bad grammar or query for {language}: {message}")]
12    Query {
13        language: &'static str,
14        message: String,
15    },
16    #[error("parser returned no tree for {0}")]
17    Parse(String),
18}
19
20/// One reusable extractor per (language, thread): parser and compiled query
21/// are pooled here, never rebuilt per file.
22pub struct Extractor {
23    spec: &'static LanguageSpec,
24    parser: Parser,
25    query: Query,
26    /// Secondary inline grammar (spec.inline): parses designated
27    /// container-node ranges of the primary tree; captures merge into
28    /// the same facts through the same contract.
29    inline: Option<(Parser, Query)>,
30}
31
32/// A definition or scope-only entry, pre-qualification.
33struct RawEntry {
34    start: usize,
35    end: usize,
36    name: String,
37    /// None for scope-only entries (e.g. impl blocks).
38    kind: Option<SymbolKind>,
39    /// Extra scope prefix from the same match (e.g. Go receiver type).
40    qualifier: Option<String>,
41    signature: String,
42    doc: Option<String>,
43}
44
45/// A reference site, pre-enclosure.
46struct RawRef {
47    start: usize,
48    end: usize,
49    name: String,
50    path: Option<String>,
51    alias: Option<String>,
52    relation: Relation,
53}
54
55/// A local binding site, pre-scoping.
56struct RawLocal {
57    start: usize,
58    end: usize,
59    name: String,
60    type_name: Option<String>,
61}
62
63/// Everything collect() gathers besides definitions.
64#[derive(Default)]
65struct Collected {
66    refs: Vec<RawRef>,
67    locals: Vec<RawLocal>,
68    /// (span, embedded type name) — owner resolved after entries exist.
69    embeds: Vec<(usize, usize, String)>,
70    /// (impl block span, trait name) — trait-impl pairing facts.
71    trait_impls: Vec<(usize, usize, String)>,
72    /// Import-alias name spans: identical local captures are the import
73    /// binding itself, not a shadow.
74    alias_spans: Vec<(usize, usize)>,
75    /// Explicit doc captures (`@doc`, e.g. Python docstrings): (span, text).
76    /// Attached to the smallest containing definition, overriding any
77    /// sibling-comment doc.
78    docs: Vec<(usize, usize, String)>,
79}
80
81impl Extractor {
82    pub fn new(spec: &'static LanguageSpec) -> Result<Self, ExtractError> {
83        let language = (spec.grammar)();
84        let mut parser = Parser::new();
85        parser
86            .set_language(&language)
87            .map_err(|e| ExtractError::Query {
88                language: spec.name,
89                message: e.to_string(),
90            })?;
91        let query = Query::new(&language, spec.query_source).map_err(|e| ExtractError::Query {
92            language: spec.name,
93            message: e.to_string(),
94        })?;
95        let inline = spec
96            .inline
97            .map(|i| {
98                let language = (i.grammar)();
99                let mut parser = Parser::new();
100                parser
101                    .set_language(&language)
102                    .map_err(|e| (spec.name, e.to_string()))?;
103                let query = Query::new(&language, i.query_source)
104                    .map_err(|e| (spec.name, e.to_string()))?;
105                Ok((parser, query))
106            })
107            .transpose()
108            .map_err(
109                |(language, message): (&'static str, String)| ExtractError::Query {
110                    language,
111                    message,
112                },
113            )?;
114        Ok(Self {
115            spec,
116            parser,
117            query,
118            inline,
119        })
120    }
121
122    /// Extract facts from one file. `file` is the repo-relative path.
123    pub fn extract(&mut self, file: &str, source: &str) -> Result<FileFacts, ExtractError> {
124        let tree = self
125            .parser
126            .parse(source, None)
127            .ok_or_else(|| ExtractError::Parse(file.to_string()))?;
128        let root = tree.root_node();
129
130        let mut entries = Vec::new();
131        let mut collected = Collected::default();
132        collect(
133            &self.query,
134            self.spec,
135            root,
136            source,
137            &mut entries,
138            &mut collected,
139        );
140        // Secondary inline grammar (spec.inline): parse the container
141        // nodes' ranges of the same source, so capture spans are already
142        // file-absolute, and merge through the identical contract.
143        if let (Some((parser, query)), Some(ispec)) = (&mut self.inline, self.spec.inline) {
144            let ranges = container_ranges(root, ispec.container_kinds);
145            if !ranges.is_empty() {
146                // Both failure modes are broken invariants (container_ranges
147                // yields sorted non-overlapping ranges; the primary parse
148                // already succeeded) — swallowing them would silently drop
149                // this file's inline refs and let the graph assert "no
150                // links" without evidence. Fail loudly like the primary.
151                parser.set_included_ranges(&ranges).map_err(|e| {
152                    ExtractError::Parse(format!("{} (inline ranges: {e})", self.spec.name))
153                })?;
154                let inline_tree = parser
155                    .parse(source, None)
156                    .ok_or_else(|| ExtractError::Parse(format!("{} (inline)", self.spec.name)))?;
157                collect(
158                    query,
159                    self.spec,
160                    inline_tree.root_node(),
161                    source,
162                    &mut entries,
163                    &mut collected,
164                );
165            }
166        }
167        entries.sort_by_key(|e| (e.start, usize::MAX - e.end));
168        // Explicit @doc captures override sibling-comment docs on the
169        // smallest definition containing them (Python docstrings).
170        for (d_start, d_end, text) in &collected.docs {
171            let owner = entries
172                .iter_mut()
173                .filter(|e| e.kind.is_some() && e.start <= *d_start && *d_end <= e.end)
174                .min_by_key(|e| e.end - e.start);
175            if let Some(entry) = owner {
176                let cleaned: Vec<&str> = text.lines().map(str::trim).collect();
177                let trimmed = cleaned.join("\n");
178                let trimmed = trimmed.trim_matches('\n');
179                if !trimmed.is_empty() {
180                    entry.doc = Some(trimmed.to_string());
181                }
182            }
183        }
184        // Two patterns may claim the same node (e.g. `const f = () => ...`
185        // as variable and function): the more specific, non-variable kind
186        // wins; sort puts identical spans adjacent.
187        entries.dedup_by(|b, a| {
188            let same = a.start == b.start && a.end == b.end && a.name == b.name;
189            if same && a.kind == Some(SymbolKind::Variable) && b.kind.is_some() {
190                a.kind = b.kind;
191            }
192            same
193        });
194
195        let file_id = NodeId::new(file);
196        let mut nodes = vec![Node {
197            id: file_id.clone(),
198            kind: SymbolKind::File,
199            name: file.to_string(),
200            file: file.to_string(),
201            span: Span {
202                start: 0,
203                end: source.len().max(1) as u64,
204            },
205            signature: String::new(),
206            doc: None,
207        }];
208        let mut contains = Vec::new();
209
210        // Containment stack: (end, scope name, node id if a real definition).
211        let mut stack: Vec<(usize, String, Option<NodeId>)> = Vec::new();
212        // (start, end, id) of each definition, for enclosing-ref lookup.
213        let mut def_spans: Vec<(usize, usize, NodeId)> = Vec::new();
214
215        for entry in &entries {
216            while stack.last().is_some_and(|(end, _, _)| *end <= entry.start) {
217                stack.pop();
218            }
219            let mut path: Vec<&str> = stack.iter().map(|(_, name, _)| name.as_str()).collect();
220            if let Some(q) = &entry.qualifier {
221                path.push(q);
222            }
223            path.push(&entry.name);
224            let qualified = path.join("::");
225            // Children nest under the entry's qualified segment.
226            let scope_segment = entry
227                .qualifier
228                .as_ref()
229                .map_or(entry.name.clone(), |q| format!("{q}::{}", entry.name));
230
231            let id = if let Some(kind) = entry.kind {
232                let id = NodeId::new(format!("{file}#{qualified}@{}", entry.start));
233                let parent = stack
234                    .iter()
235                    .rev()
236                    .find_map(|(_, _, id)| id.clone())
237                    .unwrap_or_else(|| file_id.clone());
238                nodes.push(Node {
239                    id: id.clone(),
240                    kind,
241                    name: entry.name.clone(),
242                    file: file.to_string(),
243                    span: Span {
244                        start: entry.start as u64,
245                        end: entry.end as u64,
246                    },
247                    signature: entry.signature.clone(),
248                    doc: entry.doc.clone(),
249                });
250                contains.push(Edge {
251                    src: parent,
252                    dst: id.clone(),
253                    relation: Relation::Contains,
254                    evidence: Evidence::Structural,
255                    confidence: Evidence::Structural.confidence(),
256                });
257                def_spans.push((entry.start, entry.end, id.clone()));
258                Some(id)
259            } else {
260                None
261            };
262            stack.push((entry.end, scope_segment, id));
263        }
264
265        let references = collected
266            .refs
267            .into_iter()
268            .map(|r| {
269                let enclosing = def_spans
270                    .iter()
271                    .filter(|(s, e, _)| *s <= r.start && r.end <= *e)
272                    .min_by_key(|(s, e, _)| e - s)
273                    .map(|(_, _, id)| id.clone());
274                Reference {
275                    file: file.to_string(),
276                    name: r.name,
277                    path: r.path,
278                    relation: r.relation,
279                    span: Span {
280                        start: r.start as u64,
281                        end: r.end as u64,
282                    },
283                    enclosing,
284                    alias: r.alias,
285                }
286            })
287            .collect();
288
289        // A local shadows from its introduction to the end of the innermost
290        // definition containing it (file end at top level). A "local" whose
291        // span is an import alias IS the import binding, not a shadow.
292        let alias_spans = collected.alias_spans;
293        let embeds = collected
294            .embeds
295            .iter()
296            .filter_map(|(start, end, type_name)| {
297                let owner = def_spans
298                    .iter()
299                    .filter(|(s, e, _)| s <= start && end <= e)
300                    .min_by_key(|(s, e, _)| e - s)
301                    .map(|(_, _, id)| id.clone())?;
302                Some(sinter_core::Embed {
303                    owner,
304                    type_name: type_name.clone(),
305                })
306            })
307            .collect();
308        let locals = collected
309            .locals
310            .into_iter()
311            .filter(|l| !alias_spans.contains(&(l.start, l.end)))
312            .map(|l| {
313                let scope_end = def_spans
314                    .iter()
315                    .filter(|(s, e, _)| *s <= l.start && l.end <= *e)
316                    .min_by_key(|(s, e, _)| e - s)
317                    .map_or(source.len() as u64, |(_, e, _)| *e as u64);
318                sinter_core::LocalBinding {
319                    file: file.to_string(),
320                    name: l.name,
321                    span: Span {
322                        start: l.start as u64,
323                        end: l.end as u64,
324                    },
325                    scope_end,
326                    type_name: l.type_name,
327                }
328            })
329            .collect();
330
331        let trait_impls = collected
332            .trait_impls
333            .iter()
334            .map(|(start, end, trait_name)| sinter_core::TraitImpl {
335                file: file.to_string(),
336                trait_name: trait_name.clone(),
337                span: Span {
338                    start: *start as u64,
339                    end: *end as u64,
340                },
341            })
342            .collect();
343        Ok(FileFacts {
344            file: file.to_string(),
345            content_hash: blake3::hash(source.as_bytes()).to_hex().to_string(),
346            has_syntax_errors: root.has_error(),
347            nodes,
348            contains,
349            references,
350            locals,
351            embeds,
352            trait_impls,
353        })
354    }
355}
356
357/// Byte ranges of every node of the given kinds, in document order —
358/// the included-range input for a secondary inline parse. Matched nodes
359/// are not descended into, so ranges never overlap.
360fn container_ranges(root: TsNode<'_>, kinds: &[&str]) -> Vec<tree_sitter::Range> {
361    let mut out = Vec::new();
362    let mut stack = vec![root];
363    while let Some(node) = stack.pop() {
364        if kinds.contains(&node.kind()) {
365            out.push(node.range());
366        } else {
367            for i in (0..node.child_count()).rev() {
368                stack.extend(node.child(i));
369            }
370        }
371    }
372    out.sort_by_key(|r| r.start_byte);
373    out
374}
375
376/// Run one query over one tree; group captures per match by the universal
377/// contract, appending to `entries`/`out` (called once per grammar).
378fn collect(
379    query: &Query,
380    spec: &LanguageSpec,
381    root: TsNode<'_>,
382    source: &str,
383    entries: &mut Vec<RawEntry>,
384    out: &mut Collected,
385) {
386    {
387        let mut cursor = QueryCursor::new();
388        let mut matches = cursor.matches(query, root, source.as_bytes());
389        while let Some(m) = matches.next() {
390            let mut def: Option<(TsNode, SymbolKind)> = None;
391            let mut scope: Option<TsNode> = None;
392            let mut name: Option<TsNode> = None;
393            let mut qualifier: Option<TsNode> = None;
394            let mut reference: Option<(TsNode, Relation)> = None;
395            let mut refpath: Option<TsNode> = None;
396            let mut import_path: Option<TsNode> = None;
397            let mut import_module: Option<TsNode> = None;
398            let mut import_name: Option<TsNode> = None;
399            let mut import_alias: Option<TsNode> = None;
400            let mut import_star = false;
401            let mut match_locals: Vec<TsNode> = Vec::new();
402            let mut local_type: Option<TsNode> = None;
403            let mut trait_name: Option<TsNode> = None;
404            let mut trait_impl: Option<TsNode> = None;
405            for cap in m.captures {
406                let cap_name = &query.capture_names()[cap.index as usize];
407                if let Some(kind_str) = cap_name.strip_prefix("def.") {
408                    if let Some(kind) = SymbolKind::from_str_opt(kind_str) {
409                        def = Some((cap.node, kind));
410                    }
411                } else if let Some(rel) = cap_name.strip_prefix("ref.") {
412                    let relation = match rel {
413                        "use" => Relation::Uses,
414                        _ => Relation::Calls,
415                    };
416                    reference = Some((cap.node, relation));
417                } else {
418                    match *cap_name {
419                        "scope" => scope = Some(cap.node),
420                        "name" => name = Some(cap.node),
421                        "qualifier" => qualifier = Some(cap.node),
422                        "refpath" => refpath = Some(cap.node),
423                        "import" => import_path = Some(cap.node),
424                        "import.module" => import_module = Some(cap.node),
425                        "import.name" => import_name = Some(cap.node),
426                        "import.alias" => import_alias = Some(cap.node),
427                        "import.star" => import_star = true,
428                        "local" => match_locals.push(cap.node),
429                        "local.type" => local_type = Some(cap.node),
430                        "trait" => trait_name = Some(cap.node),
431                        "trait.impl" => trait_impl = Some(cap.node),
432                        "doc" => out.docs.push((
433                            cap.node.start_byte(),
434                            cap.node.end_byte(),
435                            text(cap.node, source).to_string(),
436                        )),
437                        "embed" => out.embeds.push((
438                            cap.node.start_byte(),
439                            cap.node.end_byte(),
440                            text(cap.node, source).to_string(),
441                        )),
442                        _ => {}
443                    }
444                }
445            }
446            let sep = spec.path_separators.first().copied().unwrap_or(".");
447            if let (Some(t), Some(block)) = (trait_name, trait_impl) {
448                out.trait_impls.push((
449                    block.start_byte(),
450                    block.end_byte(),
451                    text(t, source).to_string(),
452                ));
453            }
454            if let Some(a) = import_alias {
455                out.alias_spans.push((a.start_byte(), a.end_byte()));
456            }
457            let alias = import_alias.map(|a| text(a, source).to_string());
458            for l in &match_locals {
459                out.locals.push(RawLocal {
460                    start: l.start_byte(),
461                    end: l.end_byte(),
462                    name: text(*l, source).to_string(),
463                    type_name: local_type.map(|t| text(t, source).to_string()),
464                });
465            }
466            if let Some(path_node) = import_path {
467                // Whole-path import (`use a::b`, `import "pkg"`), possibly
468                // with an alias, Go's dot form, or glob semantics
469                // (`@import.star` alongside: bash `source` binds every name).
470                out.refs.push(RawRef {
471                    start: path_node.start_byte(),
472                    end: path_node.end_byte(),
473                    name: text(path_node, source)
474                        .trim_matches(['"', '\'', '`'])
475                        .to_string(),
476                    path: None,
477                    alias: alias.or_else(|| import_star.then(|| "*".to_string())),
478                    relation: Relation::Imports,
479                });
480            } else if let (Some(module), Some(item)) = (import_module, import_name) {
481                // From-style import: module and item joined so the import
482                // binds the item itself. Alias renames the local binding.
483                let module_text = text(module, source).trim_matches(['"', '\'', '`']);
484                out.refs.push(RawRef {
485                    start: module.start_byte().min(item.start_byte()),
486                    end: item.end_byte().max(module.end_byte()),
487                    name: format!("{module_text}{sep}{}", text(item, source)),
488                    path: None,
489                    alias,
490                    relation: Relation::Imports,
491                });
492            } else if let (Some(module), true) = (import_module, import_star) {
493                // Glob import: every top-level name of the module is bound.
494                let module_text = text(module, source).trim_matches(['"', '\'', '`']);
495                out.refs.push(RawRef {
496                    start: module.start_byte(),
497                    end: module.end_byte(),
498                    name: format!("{module_text}{sep}*"),
499                    path: None,
500                    alias: Some("*".to_string()),
501                    relation: Relation::Imports,
502                });
503            }
504            if let Some((node, relation)) = reference {
505                out.refs.push(RawRef {
506                    start: node.start_byte(),
507                    end: node.end_byte(),
508                    name: text(node, source).to_string(),
509                    path: refpath.map(|p| text(p, source).to_string()),
510                    alias: None,
511                    relation,
512                });
513            }
514            let container = def.map(|(n, _)| n).or(scope);
515            if let (Some(container), Some(name_node)) = (container, name) {
516                entries.push(RawEntry {
517                    start: container.start_byte(),
518                    end: container.end_byte(),
519                    name: text(name_node, source).to_string(),
520                    kind: def.map(|(_, k)| k),
521                    qualifier: qualifier.map(|q| text(q, source).to_string()),
522                    signature: signature(container, source),
523                    doc: doc_comment(container, source, spec.comment_kinds, spec.doc_skip_kinds),
524                });
525            }
526        }
527    }
528}
529
530fn text<'a>(node: TsNode<'_>, source: &'a str) -> &'a str {
531    &source[node.start_byte()..node.end_byte()]
532}
533
534/// Declaration text up to the body. Brace languages cut at the first `{`;
535/// a first line ending in `:` (Python-style) is the whole signature.
536fn signature(node: TsNode<'_>, source: &str) -> String {
537    let t = text(node, source);
538    let first_line = t.lines().next().unwrap_or(t);
539    let head = if first_line.trim_end().ends_with(':') || first_line.contains('{') {
540        first_line.split('{').next().unwrap_or(first_line)
541    } else {
542        let up_to_brace = t.split('{').next().unwrap_or(t);
543        if up_to_brace.len() == t.len() {
544            first_line
545        } else {
546            up_to_brace
547        }
548    };
549    head.split_whitespace().collect::<Vec<_>>().join(" ")
550}
551
552/// Contiguous comment siblings immediately above the definition (or its
553/// parent declaration), stripped of comment markers. Generic across
554/// languages: comment node kinds come from the spec.
555fn doc_comment(
556    node: TsNode<'_>,
557    source: &str,
558    comment_kinds: &[&str],
559    skip_kinds: &[&str],
560) -> Option<String> {
561    let comments = preceding_comments(node, comment_kinds, skip_kinds).or_else(|| {
562        node.parent()
563            .and_then(|p| preceding_comments(p, comment_kinds, skip_kinds))
564    })?;
565    let mut lines = Vec::new();
566    for c in comments {
567        for line in text(c, source).lines() {
568            let mut l = line.trim();
569            for marker in ["///", "//!", "//", "/**", "/*", "*/", "--"] {
570                if let Some(stripped) = l.strip_prefix(marker) {
571                    l = stripped;
572                    break;
573                }
574            }
575            // Block-comment continuation: a leading `*` (Javadoc/C-style
576            // interior line) is decoration — but `**bold**` is markdown.
577            if let Some(rest) = l.strip_prefix('*')
578                && !l.starts_with("**")
579            {
580                l = rest;
581            }
582            l = l.strip_suffix("*/").unwrap_or(l);
583            lines.push(l.trim());
584        }
585    }
586    while lines.first().is_some_and(|l| l.is_empty()) {
587        lines.remove(0);
588    }
589    while lines.last().is_some_and(|l| l.is_empty()) {
590        lines.pop();
591    }
592    if lines.is_empty() {
593        None
594    } else {
595        Some(lines.join("\n"))
596    }
597}
598
599fn preceding_comments<'t>(
600    node: TsNode<'t>,
601    comment_kinds: &[&str],
602    skip_kinds: &[&str],
603) -> Option<Vec<TsNode<'t>>> {
604    let mut comments = Vec::new();
605    let mut cur = node.prev_named_sibling();
606    let mut skips = 0;
607    while let Some(sib) = cur {
608        if !comment_kinds.contains(&sib.kind()) {
609            // Step over decorator-style macro lines (UCLASS, UPROPERTY)
610            // that sit between a definition and its doc comment.
611            if skips < 2 && comments.is_empty() && skip_kinds.contains(&sib.kind()) {
612                skips += 1;
613                cur = sib.prev_named_sibling();
614                continue;
615            }
616            break;
617        }
618        comments.push(sib);
619        cur = sib.prev_named_sibling();
620    }
621    comments.reverse();
622    if comments.is_empty() {
623        None
624    } else {
625        Some(comments)
626    }
627}