1use std::collections::HashMap;
7use std::path::{Path, MAIN_SEPARATOR};
8
9use crate::facts::{ContractFact, FileFacts, Ref, TypeHint, TypeSource, ValueFact};
10use crate::lang::{self, LangSpec, RawContract, Target};
11use crate::model::{
12 Edge, EdgeKind, Fingerprint, GlobalSymbolId, Language, Position, Range, RepoId, Symbol,
13 SymbolKind,
14};
15use crate::packages::RepoPackages;
16use anyhow::{Context, Result};
17use ignore::WalkBuilder;
18use rayon::prelude::*;
19use tree_sitter::{Node, Parser};
20
21const SCHEME: &str = "comindly-treesitter";
22
23pub const RETURN: &str = "<return>";
25
26#[derive(Debug, Default)]
28pub struct ParseOutput {
29 pub symbols: Vec<Symbol>,
30 pub edges: Vec<Edge>,
31 pub facts: Vec<FileFacts>,
32}
33
34impl ParseOutput {
35 fn merge(&mut self, other: ParseOutput) {
36 self.symbols.extend(other.symbols);
37 self.edges.extend(other.edges);
38 self.facts.extend(other.facts);
39 }
40
41 fn dedup(&mut self) {
43 use std::collections::HashSet;
44
45 let mut last_idx: HashMap<String, usize> = HashMap::new();
46 for (i, s) in self.symbols.iter().enumerate() {
47 last_idx.insert(s.id.render(), i);
48 }
49 if last_idx.len() != self.symbols.len() {
50 let keep: HashSet<usize> = last_idx.into_values().collect();
51 let mut i = 0;
52 self.symbols.retain(|_| {
53 let k = keep.contains(&i);
54 i += 1;
55 k
56 });
57 }
58
59 let mut seen: HashSet<(String, String, String)> = HashSet::new();
60 self.edges
61 .retain(|e| seen.insert((e.src.render(), e.dst.render(), format!("{:?}", e.kind))));
62 }
63}
64
65fn detect(path: &Path) -> Option<Language> {
67 match path.extension().and_then(|e| e.to_str()) {
68 Some("py" | "pyi") => Some(Language::Python),
69 Some("ts" | "tsx") => Some(Language::TypeScript),
70 Some("js" | "jsx" | "mjs" | "cjs") => Some(Language::JavaScript),
71 Some("go") => Some(Language::Go),
72 Some("java") => Some(Language::Java),
73 Some("kt" | "kts") => Some(Language::Kotlin),
74 Some("rs") => Some(Language::Rust),
75 _ => None,
76 }
77}
78
79fn tree_sitter_language(lang: &Language, is_tsx: bool) -> Option<tree_sitter::Language> {
80 match lang {
81 Language::Python => Some(tree_sitter_python::LANGUAGE.into()),
82 Language::TypeScript | Language::JavaScript if is_tsx => {
83 Some(tree_sitter_typescript::LANGUAGE_TSX.into())
84 }
85 Language::TypeScript | Language::JavaScript => {
86 Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())
87 }
88 Language::Go => Some(tree_sitter_go::LANGUAGE.into()),
89 Language::Java => Some(tree_sitter_java::LANGUAGE.into()),
90 Language::Kotlin => Some(tree_sitter_kotlin_ng::LANGUAGE.into()),
91 Language::Rust => Some(tree_sitter_rust::LANGUAGE.into()),
92 _ => None,
93 }
94}
95
96pub fn parse_repo(root: &Path, repo_name: &str) -> Result<ParseOutput> {
98 let files: Vec<String> = WalkBuilder::new(root)
99 .hidden(false)
100 .build()
101 .filter_map(|e| e.ok())
102 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
103 .filter(|e| detect(e.path()).is_some())
104 .filter_map(|e| {
105 e.path()
106 .strip_prefix(root)
107 .ok()
108 .map(|r| r.to_string_lossy().replace(MAIN_SEPARATOR, "/"))
109 })
110 .collect();
111 let mut out = parse_files(root, repo_name, &files);
112 out.facts.extend(crate::configvals::scan(root, repo_name));
113 for (s, f) in crate::infra::scan(root, repo_name) {
114 out.symbols.push(s);
115 out.facts.push(f);
116 }
117 Ok(out)
118}
119
120pub fn parse_files(root: &Path, repo_name: &str, rel_paths: &[String]) -> ParseOutput {
123 let packages = RepoPackages::discover(root);
124 let mut out = rel_paths
125 .par_iter()
126 .filter_map(|rel| {
127 let full = root.join(rel);
128 full.is_file()
129 .then(|| {
130 parse_file_with(&full, repo_name, root, &packages)
131 .ok()
132 .flatten()
133 })
134 .flatten()
135 })
136 .reduce(ParseOutput::default, |mut acc, o| {
137 acc.merge(o);
138 acc
139 });
140 out.dedup();
141 out.facts.extend(
143 rel_paths
144 .iter()
145 .filter(|r| crate::configvals::is_config(r) && detect(&root.join(r)).is_none())
146 .filter_map(|r| crate::configvals::file(root, repo_name, r)),
147 );
148 let tf: Vec<String> = rel_paths
149 .iter()
150 .filter(|r| crate::infra::is_tf(r) && root.join(r).is_file())
151 .cloned()
152 .collect();
153 if !tf.is_empty() {
154 for (s, f) in crate::infra::files(root, repo_name, &tf) {
155 out.symbols.push(s);
156 out.facts.push(f);
157 }
158 }
159 out
160}
161
162pub fn parse_file(path: &Path, repo_name: &str, root: &Path) -> Result<Option<ParseOutput>> {
164 parse_file_with(path, repo_name, root, &RepoPackages::discover(root))
165}
166
167fn parse_file_with(
168 path: &Path,
169 repo_name: &str,
170 root: &Path,
171 packages: &RepoPackages,
172) -> Result<Option<ParseOutput>> {
173 let Some(lang) = detect(path) else {
174 return Ok(None);
175 };
176 let is_tsx = path.extension().and_then(|e| e.to_str()) == Some("tsx");
177 let (Some(ts_lang), Some(spec)) = (tree_sitter_language(&lang, is_tsx), lang::spec(&lang))
178 else {
179 return Ok(None);
180 };
181
182 let source = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
183 let rel = path.strip_prefix(root).unwrap_or(path);
184 let rel_str = rel.to_string_lossy().replace(MAIN_SEPARATOR, "/");
185
186 let mut parser = Parser::new();
187 parser
188 .set_language(&ts_lang)
189 .context("set tree-sitter language")?;
190 let Some(tree) = parser.parse(&source, None) else {
191 return Ok(None);
192 };
193
194 let repo = RepoId(repo_name.to_string());
195 let module = module_path(&rel_str);
196 let file_id = id(repo_name, &module);
197 let mut out = ParseOutput {
198 symbols: vec![Symbol {
199 id: file_id.clone(),
200 name: rel_str.clone(),
201 kind: SymbolKind::File,
202 language: lang.clone(),
203 repo: repo.clone(),
204 file_path: rel_str.clone(),
205 range: node_range(&tree.root_node()),
206 signature: None,
207 docstring: None,
208 fingerprint: Some(fingerprint(tree.root_node(), &source, &lang)),
209 }],
210 edges: Vec::new(),
211 facts: vec![FileFacts {
212 repo: repo_name.to_string(),
213 path: rel_str.clone(),
214 language: lang.clone(),
215 file_id: file_id.render(),
216 modules: spec
217 .package(tree.root_node(), &source)
218 .map(|p| vec![p])
219 .unwrap_or_else(|| packages.module_names(&rel_str, &lang)),
220 imports: Vec::new(),
221 refs: Vec::new(),
222 types: Vec::new(),
223 scip: None,
224 contracts: Vec::new(),
225 params: Vec::new(),
226 calls: Vec::new(),
227 values: Vec::new(),
228 }],
229 };
230
231 let ctx = Ctx {
232 source: &source,
233 packages,
234 lang: &lang,
235 spec,
236 repo: &repo,
237 rel_str: &rel_str,
238 repo_name,
239 };
240 let core = if lang == Language::Go {
242 rel_str.rsplit_once('/').map_or("", |(d, _)| d).to_string()
243 } else {
244 module
245 };
246 let scope = Scope {
247 core,
248 id: file_id.clone(),
249 callable: file_id,
250 class: None,
251 in_class: false,
252 module_level: true,
253 };
254 walk(tree.root_node(), &scope, &ctx, &mut out);
255 next_routes(&rel_str, &mut out);
256 Ok(Some(out))
257}
258
259struct Ctx<'a> {
260 source: &'a [u8],
261 packages: &'a RepoPackages,
262 lang: &'a Language,
263 spec: &'static dyn LangSpec,
264 repo: &'a RepoId,
265 rel_str: &'a str,
266 repo_name: &'a str,
267}
268
269#[derive(Clone)]
270struct Scope {
271 core: String,
273 id: GlobalSymbolId,
275 callable: GlobalSymbolId,
277 class: Option<GlobalSymbolId>,
279 in_class: bool,
281 module_level: bool,
283}
284
285fn walk(node: Node, scope: &Scope, ctx: &Ctx, out: &mut ParseOutput) {
286 for raw in ctx.spec.contracts(node, ctx.source) {
288 contract(raw, node, scope, ctx, out);
289 }
290 if let Some((kind, name)) = ctx.spec.def(node, ctx.source, scope.in_class) {
291 define(node, kind, &name, scope, ctx, out);
292 return;
293 }
294
295 if let Some(mut imports) = ctx.spec.import(node, ctx.source) {
296 for imp in imports.iter_mut().filter(|i| {
297 matches!(ctx.lang, Language::TypeScript | Language::JavaScript)
298 && !i.module.starts_with('.')
299 }) {
300 imp.file = ctx.packages.ts_alias(ctx.rel_str, &imp.module);
301 }
302 if *ctx.lang == Language::Rust {
303 let own = facts(out).modules.first().cloned();
304 for imp in &mut imports {
305 imp.module = rust_absolute(&imp.module, own.as_deref());
306 }
307 }
308 facts(out).imports.extend(imports);
309 return; }
311
312 if let Some((ty, kind, path)) = ctx.spec.owned_bases(node, ctx.source) {
313 let from = id(ctx.repo_name, &format!("{}#", join_core(&scope.core, &ty)));
314 facts(out).refs.push(Ref {
315 kind,
316 from: from.render(),
317 path,
318 line: lang::line(node),
319 });
320 }
321
322 if let Some((callee, args)) = ctx.spec.call(node, ctx.source) {
323 facts(out).calls.push(crate::facts::CallFact {
324 from: scope.callable.render(),
325 callee,
326 args,
327 line: lang::line(node),
328 });
329 }
330
331 if let Some((kind, path)) = ctx.spec.reference(node, ctx.source) {
332 facts(out).refs.push(Ref {
333 kind,
334 from: scope.callable.render(),
335 path,
336 line: lang::line(node),
337 });
338 }
339
340 let module_level = scope.module_level;
341 if module_level {
342 if let Some(name) = ctx.spec.variable(node, ctx.source) {
343 variable(node, &name, scope, ctx, out);
344 }
345 }
346 {
347 if let Some((name, value)) = ctx.spec.value(node, ctx.source) {
348 let attr = ["self.", "this."]
350 .iter()
351 .find_map(|r| name.strip_prefix(r))
352 .map(str::to_string);
353 let owner_name = match (attr, &scope.class) {
354 (Some(a), Some(c)) => Some((c.clone(), a)),
355 (Some(_), None) => None,
356 (None, _) if scope.in_class => Some((scope.id.clone(), name)),
357 (None, _) => Some((scope.callable.clone(), name)),
358 };
359 if let Some((owner, name)) = owner_name {
360 facts(out).values.push(ValueFact {
361 scope: owner.render(),
362 name,
363 value,
364 });
365 }
366 }
367 }
368 if let Some((target, source)) = ctx.spec.assignment(node, ctx.source) {
369 type_hint(target, source, scope, ctx, out);
370 }
371
372 for c in children(node) {
373 walk(c, scope, ctx, out);
374 }
375}
376
377fn define(
378 node: Node,
379 kind: SymbolKind,
380 name: &str,
381 scope: &Scope,
382 ctx: &Ctx,
383 out: &mut ParseOutput,
384) {
385 let owner = ctx.spec.owner(node, ctx.source).map(|o| {
387 let core = join_core(&scope.core, &o);
388 (id(ctx.repo_name, &format!("{core}#")), core)
389 });
390 let (parent, parent_core, parent_class) = match &owner {
391 Some((oid, ocore)) => (oid.clone(), ocore.as_str(), Some(oid.clone())),
392 None => (scope.id.clone(), scope.core.as_str(), scope.class.clone()),
393 };
394 let core = join_core(parent_core, name);
395 let sym_id = id(ctx.repo_name, &format!("{}{}", core, suffix(&kind)));
396 out.symbols.push(Symbol {
397 id: sym_id.clone(),
398 name: name.to_string(),
399 kind: kind.clone(),
400 language: ctx.lang.clone(),
401 repo: ctx.repo.clone(),
402 file_path: ctx.rel_str.to_string(),
403 range: node_range(&node),
404 signature: Some(signature(node, ctx.source)),
405 docstring: None,
406 fingerprint: Some(fingerprint(node, ctx.source, ctx.lang)),
407 });
408 out.edges
409 .push(edge(&parent, &sym_id, EdgeKind::Contains, 1.0));
410
411 let callable = is_callable(&kind);
412 let class_like = matches!(
413 kind,
414 SymbolKind::Class
415 | SymbolKind::Interface
416 | SymbolKind::Struct
417 | SymbolKind::Trait
418 | SymbolKind::Enum
419 );
420 let rendered = sym_id.render();
421 let line = lang::line(node);
422 for (kind, path) in ctx.spec.bases(node, ctx.source) {
423 facts(out).refs.push(Ref {
424 kind,
425 from: rendered.clone(),
426 path,
427 line,
428 });
429 }
430 if callable {
431 let names = ctx.spec.param_names(node, ctx.source);
432 if !names.is_empty() {
433 facts(out).params.push(crate::facts::ParamFact {
434 func: rendered.clone(),
435 names,
436 });
437 }
438 if let Some(ty) = ctx.spec.returns(node, ctx.source) {
439 facts(out).types.push(TypeHint {
440 scope: rendered.clone(),
441 name: RETURN.into(),
442 source: TypeSource::Annotation(ty),
443 });
444 }
445 for (param, ty, property) in ctx.spec.params(node, ctx.source) {
446 let f = facts(out);
447 if property {
448 if let Some(class) = &parent_class {
449 f.types.push(TypeHint {
450 scope: class.render(),
451 name: format!("{}.{param}", ctx.spec.receiver()),
452 source: TypeSource::Annotation(ty.clone()),
453 });
454 }
455 }
456 f.types.push(TypeHint {
457 scope: rendered.clone(),
458 name: param,
459 source: TypeSource::Annotation(ty),
460 });
461 }
462 }
463
464 let child = Scope {
465 core,
466 id: sym_id.clone(),
467 callable: if callable {
468 sym_id.clone()
469 } else {
470 scope.callable.clone()
471 },
472 class: if class_like {
473 Some(sym_id)
474 } else {
475 parent_class
476 },
477 in_class: class_like,
478 module_level: kind == SymbolKind::Module,
479 };
480 for c in children(node) {
481 walk(c, &child, ctx, out);
482 }
483}
484
485fn variable(node: Node, name: &str, scope: &Scope, ctx: &Ctx, out: &mut ParseOutput) {
487 let kind = if name.chars().any(|c| c.is_ascii_uppercase())
488 && !name.chars().any(|c| c.is_ascii_lowercase())
489 {
490 SymbolKind::Constant
491 } else {
492 SymbolKind::Variable
493 };
494 let sym_id = id(
495 ctx.repo_name,
496 &format!("{}{}", join_core(&scope.core, name), suffix(&kind)),
497 );
498 out.symbols.push(Symbol {
499 id: sym_id.clone(),
500 name: name.to_string(),
501 kind,
502 language: ctx.lang.clone(),
503 repo: ctx.repo.clone(),
504 file_path: ctx.rel_str.to_string(),
505 range: node_range(&node),
506 signature: Some(signature(node, ctx.source)),
507 docstring: None,
508 fingerprint: Some(fingerprint(node, ctx.source, ctx.lang)),
509 });
510 out.edges
511 .push(edge(&scope.id, &sym_id, EdgeKind::Contains, 1.0));
512}
513
514fn contract(raw: RawContract, node: Node, scope: &Scope, ctx: &Ctx, out: &mut ParseOutput) {
515 let line = lang::line(node);
516 let fact = match raw {
517 RawContract::Route {
518 handler,
519 receiver,
520 method,
521 path,
522 client,
523 } => ContractFact::Route {
524 handler: id(
525 ctx.repo_name,
526 &format!(
527 "{}{}",
528 join_core(&scope.core, &handler),
529 suffix(&SymbolKind::Function)
530 ),
531 )
532 .render(),
533 receiver,
534 method,
535 path,
536 line,
537 client,
538 },
539 RawContract::Handle {
540 receiver,
541 method,
542 path,
543 handler,
544 } => ContractFact::Handle {
545 from: scope.callable.render(),
546 receiver,
547 method,
548 path,
549 handler,
550 line,
551 },
552 RawContract::Construct { target, ctor, args } => {
553 let (owner, name) = match target {
554 Target::Var(n) => (scope.callable.render(), n),
555 Target::Attr(n) => match &scope.class {
556 Some(c) => (c.render(), format!("{}.{n}", ctx.spec.receiver())),
557 None => return,
558 },
559 };
560 ContractFact::Construct {
561 scope: owner,
562 target: name,
563 ctor,
564 args,
565 }
566 }
567 RawContract::Op {
568 receiver,
569 method,
570 args,
571 } => ContractFact::Op {
572 from: scope.callable.render(),
573 receiver,
574 method,
575 args,
576 line,
577 },
578 RawContract::TableName(name) => match &scope.class {
579 Some(c) => ContractFact::Table {
580 class: c.render(),
581 name,
582 },
583 None => return,
584 },
585 };
586 facts(out).contracts.push(fact);
587}
588
589fn type_hint(target: Target, source: TypeSource, scope: &Scope, ctx: &Ctx, out: &mut ParseOutput) {
590 let source = match source {
592 TypeSource::Alias { name, .. } => TypeSource::Alias {
593 name,
594 scope: scope.callable.render(),
595 },
596 s => s,
597 };
598 let hint = match target {
599 Target::Var(name) => TypeHint {
600 scope: scope.callable.render(),
601 name,
602 source,
603 },
604 Target::Attr(name) => {
605 let Some(class) = &scope.class else {
606 return;
607 };
608 TypeHint {
609 scope: class.render(),
610 name: format!("{}.{name}", ctx.spec.receiver()),
611 source,
612 }
613 }
614 };
615 facts(out).types.push(hint);
616}
617
618fn next_routes(rel: &str, out: &mut ParseOutput) {
620 let Some(path) = crate::lang::typescript::next_route_path(rel) else {
621 return;
622 };
623 let handlers: Vec<(String, String, u32)> = out
624 .symbols
625 .iter()
626 .filter(|s| s.kind == SymbolKind::Function)
627 .filter(|s| {
628 matches!(
629 s.name.as_str(),
630 "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"
631 )
632 })
633 .map(|s| (s.id.render(), s.name.clone(), s.range.start.line))
634 .collect();
635 for (handler, method, line) in handlers {
636 facts(out).contracts.push(ContractFact::Route {
637 handler,
638 receiver: Vec::new(),
639 method,
640 path: crate::facts::ValueExpr::Lit(path.clone()),
641 line,
642 client: false,
643 });
644 }
645}
646
647fn facts(out: &mut ParseOutput) -> &mut FileFacts {
649 out.facts.last_mut().expect("file facts initialised")
650}
651
652fn fingerprint(node: Node, src: &[u8], lang: &Language) -> Fingerprint {
656 let body_start = node
657 .child_by_field_name("body")
658 .map_or(node.end_byte(), |b| b.start_byte());
659 let (mut sig, mut body, mut shape) = (
660 blake3::Hasher::new(),
661 blake3::Hasher::new(),
662 blake3::Hasher::new(),
663 );
664 let mut renames: HashMap<&[u8], u32> = HashMap::new();
665 let mut stack = vec![node];
666 while let Some(n) = stack.pop() {
667 if n.kind().contains("comment") || is_docstring(n, lang) {
668 continue;
669 }
670 if n.child_count() > 0 {
671 let mut cur = n.walk();
672 let kids: Vec<Node> = n.children(&mut cur).collect();
673 stack.extend(kids.into_iter().rev());
674 continue;
675 }
676 let text = &src[n.start_byte()..n.end_byte()];
677 for h in [&mut body, &mut shape] {
678 h.update(n.kind().as_bytes());
679 h.update(b"\0");
680 }
681 body.update(text);
682 body.update(b"\0");
683 if n.start_byte() < body_start {
684 sig.update(n.kind().as_bytes());
685 sig.update(b"\0");
686 sig.update(text);
687 sig.update(b"\0");
688 }
689 if n.kind().ends_with("identifier") {
690 let next = renames.len() as u32;
691 shape.update(&renames.entry(text).or_insert(next).to_le_bytes());
692 } else {
693 shape.update(text);
694 }
695 shape.update(b"\0");
696 }
697 let short =
698 |h: blake3::Hasher| u64::from_le_bytes(h.finalize().as_bytes()[..8].try_into().unwrap());
699 Fingerprint {
700 sig: short(sig),
701 body: short(body),
702 shape: short(shape),
703 }
704}
705
706fn is_docstring(n: Node, lang: &Language) -> bool {
708 matches!(lang, Language::Python)
709 && n.kind() == "expression_statement"
710 && n.named_child_count() == 1
711 && n.named_child(0).is_some_and(|c| c.kind() == "string")
712 && n.prev_named_sibling().is_none()
713 && n.parent()
714 .is_some_and(|p| matches!(p.kind(), "block" | "module"))
715}
716
717fn is_callable(kind: &SymbolKind) -> bool {
718 matches!(kind, SymbolKind::Function | SymbolKind::Method)
719}
720
721fn suffix(kind: &SymbolKind) -> &'static str {
722 match kind {
723 SymbolKind::Class
724 | SymbolKind::Interface
725 | SymbolKind::Struct
726 | SymbolKind::Enum
727 | SymbolKind::Trait
728 | SymbolKind::TypeAlias => "#",
729 SymbolKind::Function | SymbolKind::Method => "().",
730 SymbolKind::Variable | SymbolKind::Constant => ".",
731 _ => "",
732 }
733}
734
735fn id(repo: &str, descriptor: &str) -> GlobalSymbolId {
736 GlobalSymbolId {
737 scheme: SCHEME.to_string(),
738 package_manager: ".".to_string(),
739 package: repo.to_string(),
740 version: ".".to_string(),
741 descriptor: descriptor.to_string(),
742 }
743}
744
745fn edge(src: &GlobalSymbolId, dst: &GlobalSymbolId, kind: EdgeKind, confidence: f32) -> Edge {
746 Edge {
747 src: src.clone(),
748 dst: dst.clone(),
749 kind,
750 confidence,
751 cross_repo: false,
752 }
753}
754
755fn rust_absolute(module: &str, own: Option<&str>) -> String {
757 let Some(own) = own else {
758 return module.to_string();
759 };
760 let mut segs: Vec<&str> = module.split("::").collect();
761 let mut base: Vec<&str> = match segs.first() {
762 Some(&"crate") => vec![own.split("::").next().unwrap_or(own)],
763 Some(&"self") => own.split("::").collect(),
764 Some(&"super") => {
765 let mut b: Vec<&str> = own.split("::").collect();
766 while segs.first() == Some(&"super") {
767 segs.remove(0);
768 b.pop();
769 }
770 segs.insert(0, "");
771 b
772 }
773 _ => return module.to_string(),
774 };
775 segs.remove(0);
776 base.extend(segs.into_iter().filter(|s| !s.is_empty()));
777 base.join("::")
778}
779
780fn join_core(core: &str, name: &str) -> String {
781 if core.is_empty() {
782 name.to_string()
783 } else {
784 format!("{core}/{name}")
785 }
786}
787
788fn module_path(rel: &str) -> String {
789 let no_ext = rel.rsplit_once('.').map_or(rel, |(base, _)| base);
790 no_ext
791 .strip_suffix("/__init__")
792 .unwrap_or(no_ext)
793 .to_string()
794}
795
796fn children(node: Node) -> Vec<Node> {
797 let mut cursor = node.walk();
798 node.children(&mut cursor).collect()
799}
800
801fn signature(node: Node, src: &[u8]) -> String {
803 const MAX: usize = 400;
804 let end = node
805 .child_by_field_name("body")
806 .map_or(node.end_byte(), |b| b.start_byte());
807 let head = std::str::from_utf8(&src[node.start_byte()..end]).unwrap_or("");
808 let head = if node.child_by_field_name("body").is_some() {
809 head
810 } else {
811 head.lines().next().unwrap_or("")
812 };
813 let mut sig = head.split_whitespace().collect::<Vec<_>>().join(" ");
814 for (from, to) in [
815 ("( ", "("),
816 ("[ ", "["),
817 (" )", ")"),
818 (" ]", "]"),
819 (",)", ")"),
820 (",]", "]"),
821 ] {
822 sig = sig.replace(from, to);
823 }
824 sig = sig
825 .trim_end_matches(|c: char| c == '{' || c == ':' || c.is_whitespace())
826 .to_string();
827 if sig.len() > MAX {
828 let cut = (0..=MAX)
829 .rev()
830 .find(|&i| sig.is_char_boundary(i))
831 .unwrap_or(0);
832 sig.truncate(cut);
833 sig.push('…');
834 }
835 sig
836}
837
838fn node_range(node: &Node) -> Range {
839 let s = node.start_position();
840 let e = node.end_position();
841 Range {
842 start: Position {
843 line: s.row as u32 + 1,
844 column: s.column as u32,
845 },
846 end: Position {
847 line: e.row as u32 + 1,
848 column: e.column as u32,
849 },
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 #[test]
858 fn a_signature_keeps_parameters_spread_over_lines() {
859 let src = "async def get_products(\n provider: str | None = None,\n page: int = 1,\n) -> Page:\n return []\n";
860 let mut p = tree_sitter::Parser::new();
861 p.set_language(&tree_sitter_python::LANGUAGE.into())
862 .unwrap();
863 let tree = p.parse(src, None).unwrap();
864 let f = tree.root_node().child(0).unwrap();
865 assert_eq!(
866 signature(f, src.as_bytes()),
867 "async def get_products(provider: str | None = None, page: int = 1) -> Page"
868 );
869 }
870 use crate::facts::RefKind;
871 use std::io::Write;
872
873 fn write(dir: &Path, name: &str, body: &str) {
874 let p = dir.join(name);
875 let mut f = std::fs::File::create(p).unwrap();
876 f.write_all(body.as_bytes()).unwrap();
877 }
878
879 #[test]
880 fn extracts_python_class_method_and_call() {
881 let dir = std::env::temp_dir().join(format!("comindly-parse-test-{}", std::process::id()));
882 std::fs::create_dir_all(&dir).unwrap();
883 write(
884 &dir,
885 "svc.py",
886 "class Service:\n def run(self):\n helper()\n\ndef helper():\n pass\n",
887 );
888
889 let out = parse_repo(&dir, "demo").unwrap();
890 std::fs::remove_dir_all(&dir).ok();
891 let kinds: Vec<_> = out
892 .symbols
893 .iter()
894 .map(|s| (s.name.as_str(), s.kind.clone()))
895 .collect();
896 assert!(
897 kinds.contains(&("Service", SymbolKind::Class)),
898 "got {kinds:?}"
899 );
900 assert!(
901 kinds.contains(&("run", SymbolKind::Method)),
902 "got {kinds:?}"
903 );
904 assert!(
905 kinds.contains(&("helper", SymbolKind::Function)),
906 "got {kinds:?}"
907 );
908
909 assert!(out.edges.iter().any(|e| e.kind == EdgeKind::Contains
910 && e.src.descriptor.contains("Service#")
911 && e.dst.descriptor.contains("run().")));
912 let refs = &out.facts[0].refs;
913 assert!(refs
914 .iter()
915 .any(|r| r.kind == RefKind::Call && r.from.contains("run().") && r.path == ["helper"]));
916 }
917
918 fn fp_of(body: &str, name: &str) -> Fingerprint {
919 let dir = std::env::temp_dir().join(format!(
920 "comindly-fp-{}-{}",
921 std::process::id(),
922 blake3::hash(body.as_bytes()).to_hex()
923 ));
924 std::fs::create_dir_all(&dir).unwrap();
925 write(&dir, "m.py", body);
926 let out = parse_repo(&dir, "demo").unwrap();
927 std::fs::remove_dir_all(&dir).ok();
928 out.symbols
929 .into_iter()
930 .find(|s| s.name == name)
931 .and_then(|s| s.fingerprint)
932 .unwrap()
933 }
934
935 #[test]
936 fn fingerprint_ignores_formatting_and_tracks_renames() {
937 let base = fp_of("def f(a):\n x = a + 1\n return x\n", "f");
938 let noisy = fp_of(
939 "def f(a):\n \"\"\"Doc.\"\"\"\n # note\n x = a + 1\n\n return x\n",
940 "f",
941 );
942 assert_eq!(base, noisy);
943
944 let renamed = fp_of("def f(b):\n y = b + 1\n return y\n", "f");
945 assert_ne!(base.body, renamed.body);
946 assert_ne!(base.sig, renamed.sig);
947 assert_eq!(base.shape, renamed.shape);
948
949 let changed = fp_of("def f(a):\n x = a + 2\n return x\n", "f");
950 assert_eq!(base.sig, changed.sig);
951 assert_ne!(base.body, changed.body);
952 assert_ne!(base.shape, changed.shape);
953 }
954
955 #[test]
956 fn python_facts_capture_imports_types_and_refs() {
957 let out = parse_repo(
958 Path::new(concat!(
959 env!("CARGO_MANIFEST_DIR"),
960 "/../comindly-engine/tests/fixtures/org/service-a"
961 )),
962 "service-a",
963 )
964 .unwrap();
965 let users = out
966 .facts
967 .iter()
968 .find(|f| f.path == "service_a/users.py")
969 .unwrap();
970 assert_eq!(users.modules, vec!["service_a.users"]);
971 assert!(users.imports.iter().any(|i| i.module == "acme.common.http"
972 && i.items
973 == vec![crate::facts::ImportItem::Name {
974 name: "Client".into(),
975 alias: Some("HttpClient".into())
976 }]));
977 assert!(users.refs.iter().any(|r| r.path == ["self", "api", "get"]));
978 assert!(users
979 .refs
980 .iter()
981 .any(|r| r.kind == RefKind::Inherits && r.path == ["BaseService"]));
982 assert!(users.types.iter().any(|t| t.name == "self.api"
983 && matches!(&t.source, TypeSource::Alias { name, .. } if name == "api")));
984 assert!(users
985 .types
986 .iter()
987 .any(|t| t.name == "client"
988 && t.source == TypeSource::Construct(vec!["HttpClient".into()])));
989 }
990
991 #[test]
992 fn tsx_components_are_references() {
993 let dir = std::env::temp_dir().join(format!("comindly-tsx-{}", std::process::id()));
994 std::fs::create_dir_all(&dir).unwrap();
995 write(
996 &dir,
997 "page.tsx",
998 "export function Page() {\n return <Layout><ui.Button label=\"x\" /><div /></Layout>;\n}\n",
999 );
1000 let out = parse_repo(&dir, "demo").unwrap();
1001 std::fs::remove_dir_all(&dir).ok();
1002 let paths: Vec<_> = out.facts[0].refs.iter().map(|r| r.path.join(".")).collect();
1003 assert!(paths.contains(&"Layout".to_string()), "{paths:?}");
1004 assert!(paths.contains(&"ui.Button".to_string()), "{paths:?}");
1005 assert!(!paths.iter().any(|p| p == "div"), "{paths:?}");
1006 }
1007
1008 #[test]
1009 fn typescript_facts_capture_reexports_and_heritage() {
1010 let kit = parse_repo(
1011 Path::new(concat!(
1012 env!("CARGO_MANIFEST_DIR"),
1013 "/../comindly-engine/tests/fixtures/org/ui-kit"
1014 )),
1015 "ui-kit",
1016 )
1017 .unwrap();
1018 let index = kit.facts.iter().find(|f| f.path == "src/index.ts").unwrap();
1019 assert_eq!(index.imports.len(), 3);
1020 assert!(index
1021 .imports
1022 .iter()
1023 .any(|i| i.module == "./button" && i.items == vec![crate::facts::ImportItem::All]));
1024
1025 let app = parse_repo(
1026 Path::new(concat!(
1027 env!("CARGO_MANIFEST_DIR"),
1028 "/../comindly-engine/tests/fixtures/org/web-app"
1029 )),
1030 "web-app",
1031 )
1032 .unwrap();
1033 let f = app.facts.iter().find(|f| f.path == "src/app.ts").unwrap();
1034 assert!(f
1035 .refs
1036 .iter()
1037 .any(|r| r.kind == RefKind::Implements && r.path == ["Theme"]));
1038 assert!(f.refs.iter().any(|r| r.path == ["kit", "formatDate"]));
1039 assert!(f.imports.iter().any(|i| i.items
1040 == vec![crate::facts::ImportItem::Module {
1041 alias: Some("kit".into())
1042 }]));
1043 }
1044}