Skip to main content

comindly_core/
parse.rs

1//! Comindly parse — tree-sitter extraction of symbols, the `Contains` hierarchy and per-file facts.
2//!
3//! The walk is language-agnostic; [`crate::lang`] answers the syntax questions per grammar.
4//! Imports, references and type hints go into [`FileFacts`], which [`crate::resolve`] binds.
5
6use 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
23/// Type-hint name for a callable's declared return type.
24pub const RETURN: &str = "<return>";
25
26/// Symbols, structural edges and per-file facts extracted from a repo (or some files of it).
27#[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    /// Collapse definitions sharing a SCIP id (e.g. `@overload` stubs), keeping the last one.
42    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
65/// Detect language from file extension. Returns `None` for files we don't parse.
66fn 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
96/// Parse every supported source file under `root`, in parallel, respecting `.gitignore`.
97pub 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
120/// Parse a specific set of repo-relative files (for incremental indexing). Unsupported or
121/// missing files are skipped.
122pub 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    // Config files only reach here on incremental runs (a full parse scans them separately).
142    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
162/// Parse a single file. `Ok(None)` means "unsupported language", not an error.
163pub 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    // Go declarations live in the package (directory), not the file.
241    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    /// Descriptor path without the kind suffix, e.g. `acme/foo/MyClass`.
272    core: String,
273    /// The `Contains` parent.
274    id: GlobalSymbolId,
275    /// Nearest enclosing function/method (or the file): where references and locals live.
276    callable: GlobalSymbolId,
277    /// Nearest enclosing class: what `self`/`this` refers to.
278    class: Option<GlobalSymbolId>,
279    /// Directly inside a class body.
280    in_class: bool,
281    /// At module level (file or inline module), not inside a callable or class.
282    module_level: bool,
283}
284
285fn walk(node: Node, scope: &Scope, ctx: &Ctx, out: &mut ParseOutput) {
286    // Before defs: annotated definitions (Spring) declare contracts in their enclosing scope.
287    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; // imported names are not references
310    }
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            // `self.x = v` / `this.x = v` describe the class, wherever they are assigned.
349            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    // A Go method belongs to its receiver type, wherever that type is declared.
386    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
485/// A module-level binding: `Constant` when SCREAMING_CASE, else `Variable`.
486fn 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    // An alias is read in the scope where the assignment happens.
591    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
618/// Next.js app-router handlers: `route.ts` exporting `GET`/`POST`/… serve the file's path.
619fn 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
647/// The single file's facts (a fresh `ParseOutput` from `parse_file_with` holds exactly one).
648fn facts(out: &mut ParseOutput) -> &mut FileFacts {
649    out.facts.last_mut().expect("file facts initialised")
650}
651
652// ---- helpers ------------------------------------------------------------------------
653
654/// Hash a definition's leaf tokens, skipping comments, Python docstrings and whitespace.
655fn 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
706/// A Python string statement that opens a block (module/class/function docstring).
707fn 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
755/// Make a Rust `use` path absolute: `crate::`, `self::` and `super::` against the file's module.
756fn 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}