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(first_line(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(first_line(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 first_line(node: Node, src: &[u8]) -> String {
802 node.utf8_text(src)
803 .unwrap_or("")
804 .lines()
805 .next()
806 .unwrap_or("")
807 .trim_end()
808 .to_string()
809}
810
811fn node_range(node: &Node) -> Range {
812 let s = node.start_position();
813 let e = node.end_position();
814 Range {
815 start: Position {
816 line: s.row as u32 + 1,
817 column: s.column as u32,
818 },
819 end: Position {
820 line: e.row as u32 + 1,
821 column: e.column as u32,
822 },
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use crate::facts::RefKind;
830 use std::io::Write;
831
832 fn write(dir: &Path, name: &str, body: &str) {
833 let p = dir.join(name);
834 let mut f = std::fs::File::create(p).unwrap();
835 f.write_all(body.as_bytes()).unwrap();
836 }
837
838 #[test]
839 fn extracts_python_class_method_and_call() {
840 let dir = std::env::temp_dir().join(format!("comindly-parse-test-{}", std::process::id()));
841 std::fs::create_dir_all(&dir).unwrap();
842 write(
843 &dir,
844 "svc.py",
845 "class Service:\n def run(self):\n helper()\n\ndef helper():\n pass\n",
846 );
847
848 let out = parse_repo(&dir, "demo").unwrap();
849 std::fs::remove_dir_all(&dir).ok();
850 let kinds: Vec<_> = out
851 .symbols
852 .iter()
853 .map(|s| (s.name.as_str(), s.kind.clone()))
854 .collect();
855 assert!(
856 kinds.contains(&("Service", SymbolKind::Class)),
857 "got {kinds:?}"
858 );
859 assert!(
860 kinds.contains(&("run", SymbolKind::Method)),
861 "got {kinds:?}"
862 );
863 assert!(
864 kinds.contains(&("helper", SymbolKind::Function)),
865 "got {kinds:?}"
866 );
867
868 assert!(out.edges.iter().any(|e| e.kind == EdgeKind::Contains
869 && e.src.descriptor.contains("Service#")
870 && e.dst.descriptor.contains("run().")));
871 let refs = &out.facts[0].refs;
872 assert!(refs
873 .iter()
874 .any(|r| r.kind == RefKind::Call && r.from.contains("run().") && r.path == ["helper"]));
875 }
876
877 fn fp_of(body: &str, name: &str) -> Fingerprint {
878 let dir = std::env::temp_dir().join(format!(
879 "comindly-fp-{}-{}",
880 std::process::id(),
881 blake3::hash(body.as_bytes()).to_hex()
882 ));
883 std::fs::create_dir_all(&dir).unwrap();
884 write(&dir, "m.py", body);
885 let out = parse_repo(&dir, "demo").unwrap();
886 std::fs::remove_dir_all(&dir).ok();
887 out.symbols
888 .into_iter()
889 .find(|s| s.name == name)
890 .and_then(|s| s.fingerprint)
891 .unwrap()
892 }
893
894 #[test]
895 fn fingerprint_ignores_formatting_and_tracks_renames() {
896 let base = fp_of("def f(a):\n x = a + 1\n return x\n", "f");
897 let noisy = fp_of(
898 "def f(a):\n \"\"\"Doc.\"\"\"\n # note\n x = a + 1\n\n return x\n",
899 "f",
900 );
901 assert_eq!(base, noisy);
902
903 let renamed = fp_of("def f(b):\n y = b + 1\n return y\n", "f");
904 assert_ne!(base.body, renamed.body);
905 assert_ne!(base.sig, renamed.sig);
906 assert_eq!(base.shape, renamed.shape);
907
908 let changed = fp_of("def f(a):\n x = a + 2\n return x\n", "f");
909 assert_eq!(base.sig, changed.sig);
910 assert_ne!(base.body, changed.body);
911 assert_ne!(base.shape, changed.shape);
912 }
913
914 #[test]
915 fn python_facts_capture_imports_types_and_refs() {
916 let out = parse_repo(
917 Path::new(concat!(
918 env!("CARGO_MANIFEST_DIR"),
919 "/../comindly-engine/tests/fixtures/org/service-a"
920 )),
921 "service-a",
922 )
923 .unwrap();
924 let users = out
925 .facts
926 .iter()
927 .find(|f| f.path == "service_a/users.py")
928 .unwrap();
929 assert_eq!(users.modules, vec!["service_a.users"]);
930 assert!(users.imports.iter().any(|i| i.module == "acme.common.http"
931 && i.items
932 == vec![crate::facts::ImportItem::Name {
933 name: "Client".into(),
934 alias: Some("HttpClient".into())
935 }]));
936 assert!(users.refs.iter().any(|r| r.path == ["self", "api", "get"]));
937 assert!(users
938 .refs
939 .iter()
940 .any(|r| r.kind == RefKind::Inherits && r.path == ["BaseService"]));
941 assert!(users.types.iter().any(|t| t.name == "self.api"
942 && matches!(&t.source, TypeSource::Alias { name, .. } if name == "api")));
943 assert!(users
944 .types
945 .iter()
946 .any(|t| t.name == "client"
947 && t.source == TypeSource::Construct(vec!["HttpClient".into()])));
948 }
949
950 #[test]
951 fn tsx_components_are_references() {
952 let dir = std::env::temp_dir().join(format!("comindly-tsx-{}", std::process::id()));
953 std::fs::create_dir_all(&dir).unwrap();
954 write(
955 &dir,
956 "page.tsx",
957 "export function Page() {\n return <Layout><ui.Button label=\"x\" /><div /></Layout>;\n}\n",
958 );
959 let out = parse_repo(&dir, "demo").unwrap();
960 std::fs::remove_dir_all(&dir).ok();
961 let paths: Vec<_> = out.facts[0].refs.iter().map(|r| r.path.join(".")).collect();
962 assert!(paths.contains(&"Layout".to_string()), "{paths:?}");
963 assert!(paths.contains(&"ui.Button".to_string()), "{paths:?}");
964 assert!(!paths.iter().any(|p| p == "div"), "{paths:?}");
965 }
966
967 #[test]
968 fn typescript_facts_capture_reexports_and_heritage() {
969 let kit = parse_repo(
970 Path::new(concat!(
971 env!("CARGO_MANIFEST_DIR"),
972 "/../comindly-engine/tests/fixtures/org/ui-kit"
973 )),
974 "ui-kit",
975 )
976 .unwrap();
977 let index = kit.facts.iter().find(|f| f.path == "src/index.ts").unwrap();
978 assert_eq!(index.imports.len(), 3);
979 assert!(index
980 .imports
981 .iter()
982 .any(|i| i.module == "./button" && i.items == vec![crate::facts::ImportItem::All]));
983
984 let app = parse_repo(
985 Path::new(concat!(
986 env!("CARGO_MANIFEST_DIR"),
987 "/../comindly-engine/tests/fixtures/org/web-app"
988 )),
989 "web-app",
990 )
991 .unwrap();
992 let f = app.facts.iter().find(|f| f.path == "src/app.ts").unwrap();
993 assert!(f
994 .refs
995 .iter()
996 .any(|r| r.kind == RefKind::Implements && r.path == ["Theme"]));
997 assert!(f.refs.iter().any(|r| r.path == ["kit", "formatDate"]));
998 assert!(f.imports.iter().any(|i| i.items
999 == vec![crate::facts::ImportItem::Module {
1000 alias: Some("kit".into())
1001 }]));
1002 }
1003}