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
20pub struct Extractor {
23 spec: &'static LanguageSpec,
24 parser: Parser,
25 query: Query,
26 inline: Option<(Parser, Query)>,
30}
31
32struct RawEntry {
34 start: usize,
35 end: usize,
36 name: String,
37 kind: Option<SymbolKind>,
39 qualifier: Option<String>,
41 signature: String,
42 doc: Option<String>,
43}
44
45struct RawRef {
47 start: usize,
48 end: usize,
49 name: String,
50 path: Option<String>,
51 alias: Option<String>,
52 relation: Relation,
53}
54
55struct RawLocal {
57 start: usize,
58 end: usize,
59 name: String,
60 type_name: Option<String>,
61}
62
63#[derive(Default)]
65struct Collected {
66 refs: Vec<RawRef>,
67 locals: Vec<RawLocal>,
68 embeds: Vec<(usize, usize, String)>,
70 trait_impls: Vec<(usize, usize, String)>,
72 alias_spans: Vec<(usize, usize)>,
75 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 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 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 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 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 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 let mut stack: Vec<(usize, String, Option<NodeId>)> = Vec::new();
212 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 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 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
357fn 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
376fn 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 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 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 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
534fn 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
552fn 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 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 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}