Skip to main content

comindly_core/
scip.rs

1//! SCIP ingestion: compiler-grade definitions and references attached to per-file facts.
2//!
3//! The resolver consults these first; tree-sitter resolution covers what SCIP doesn't.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result};
10use protobuf::Message;
11
12use crate::facts::{FileFacts, ScipFacts, ScipRef};
13use crate::model::{Symbol, SymbolKind};
14
15const DEFINITION: i32 = 1;
16
17/// How `index`/`link` use SCIP: ingest/run when available, insist, or skip.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Mode {
20    Auto,
21    Require,
22    Off,
23}
24
25impl Mode {
26    pub fn parse(s: &str) -> Option<Mode> {
27        match s {
28            "auto" => Some(Mode::Auto),
29            "require" => Some(Mode::Require),
30            "off" => Some(Mode::Off),
31            _ => None,
32        }
33    }
34}
35
36/// Outcome of attaching SCIP data to one repo.
37#[derive(Debug, Default)]
38pub struct Attached {
39    pub indexes: usize,
40    pub documents: usize,
41    pub definitions: usize,
42    pub references: usize,
43}
44
45/// Find or produce SCIP indexes for `root` and attach them to this repo's facts.
46pub fn ingest(
47    mode: Mode,
48    root: &Path,
49    repo: &str,
50    symbols: &[Symbol],
51    facts: &mut [FileFacts],
52) -> Result<Attached> {
53    let mut out = Attached::default();
54    if mode == Mode::Off {
55        return Ok(out);
56    }
57    let mut indexes = find_indexes(root);
58    let tmp = std::env::temp_dir().join(format!("comindly-scip-{}-{repo}", std::process::id()));
59    if indexes.is_empty() {
60        indexes = run_indexers(root, &tmp);
61    }
62    for (path, prefix) in &indexes {
63        let a = attach(path, prefix, repo, symbols, facts)
64            .with_context(|| format!("read {}", path.display()))?;
65        out.indexes += 1;
66        out.documents += a.documents;
67        out.definitions += a.definitions;
68        out.references += a.references;
69    }
70    std::fs::remove_dir_all(&tmp).ok();
71    if mode == Mode::Require && out.documents == 0 {
72        anyhow::bail!("{repo}: no SCIP index found or produced (scip = \"require\")");
73    }
74    Ok(out)
75}
76
77/// `index.scip` files under `root` (gitignored ones included), with their repo-relative dir.
78fn find_indexes(root: &Path) -> Vec<(PathBuf, String)> {
79    ignore::WalkBuilder::new(root)
80        .standard_filters(false)
81        .max_depth(Some(4))
82        .filter_entry(|e| {
83            !matches!(
84                e.file_name().to_str(),
85                Some("node_modules" | ".git" | "target" | ".venv" | "venv" | "dist")
86            )
87        })
88        .build()
89        .filter_map(|e| e.ok())
90        .filter(|e| e.file_name() == "index.scip")
91        .filter_map(|e| {
92            let dir = e.path().parent()?.strip_prefix(root).ok()?;
93            Some((
94                e.path().to_path_buf(),
95                dir.to_string_lossy().replace('\\', "/"),
96            ))
97        })
98        .collect()
99}
100
101/// Run the SCIP indexers on `PATH` that match the repo's manifests; returns produced indexes.
102fn run_indexers(root: &Path, tmp: &Path) -> Vec<(PathBuf, String)> {
103    let has = |f: &str| root.join(f).is_file();
104    let mut jobs: Vec<(&str, Vec<String>)> = Vec::new();
105    if has("tsconfig.json") || has("package.json") {
106        jobs.push((
107            "scip-typescript",
108            vec!["index".into(), "--infer-tsconfig".into()],
109        ));
110    }
111    if has("pyproject.toml") || has("setup.py") || has("requirements.txt") {
112        jobs.push(("scip-python", vec!["index".into(), ".".into()]));
113    }
114    if has("go.mod") {
115        jobs.push(("scip-go", Vec::new()));
116    }
117    if has("Cargo.toml") {
118        jobs.push(("rust-analyzer", vec!["scip".into(), ".".into()]));
119    }
120    if has("pom.xml") || has("build.gradle") || has("build.gradle.kts") {
121        jobs.push(("scip-java", vec!["index".into()]));
122    }
123    let mut out = Vec::new();
124    for (i, (bin, mut args)) in jobs.into_iter().enumerate() {
125        if which(bin).is_none() {
126            continue;
127        }
128        if std::fs::create_dir_all(tmp).is_err() {
129            break;
130        }
131        let file = tmp.join(format!("{i}-{bin}.scip"));
132        args.push("--output".into());
133        args.push(file.to_string_lossy().to_string());
134        let ok = Command::new(bin)
135            .args(&args)
136            .current_dir(root)
137            .output()
138            .is_ok_and(|o| o.status.success());
139        if ok && file.is_file() {
140            out.push((file, String::new()));
141        }
142    }
143    out
144}
145
146pub fn which(bin: &str) -> Option<PathBuf> {
147    std::env::split_paths(&std::env::var_os("PATH")?)
148        .map(|d| d.join(bin))
149        .find(|p| p.is_file())
150}
151
152/// Attach one index's occurrences to facts of `repo`; document paths are relative to `prefix`.
153pub fn attach(
154    index: &Path,
155    prefix: &str,
156    repo: &str,
157    symbols: &[Symbol],
158    facts: &mut [FileFacts],
159) -> Result<Attached> {
160    let bytes = std::fs::read(index)?;
161    let idx = scip::types::Index::parse_from_bytes(&bytes).context("decode SCIP index")?;
162
163    let mut by_file: HashMap<&str, Vec<&Symbol>> = HashMap::new();
164    for s in symbols
165        .iter()
166        .filter(|s| s.repo.0 == repo && !matches!(s.kind, SymbolKind::File))
167    {
168        by_file.entry(s.file_path.as_str()).or_default().push(s);
169    }
170    let mut slots: HashMap<String, usize> = HashMap::new();
171    for (i, f) in facts.iter().enumerate().filter(|(_, f)| f.repo == repo) {
172        slots.insert(f.path.clone(), i);
173    }
174
175    let mut out = Attached::default();
176    for doc in &idx.documents {
177        let path = if prefix.is_empty() {
178            doc.relative_path.clone()
179        } else {
180            format!("{prefix}/{}", doc.relative_path)
181        };
182        let Some(&slot) = slots.get(&path) else {
183            continue;
184        };
185        out.documents += 1;
186        let defs_here = by_file.get(path.as_str()).cloned().unwrap_or_default();
187        let sf = facts[slot].scip.get_or_insert_with(ScipFacts::default);
188        for occ in &doc.occurrences {
189            if occ.symbol.is_empty() || scip::symbol::is_local_symbol(&occ.symbol) {
190                continue;
191            }
192            let (Some(&line0), Some(name)) = (occ.range.first(), last_name(&occ.symbol)) else {
193                continue;
194            };
195            let line = line0 as u32 + 1;
196            if occ.symbol_roles & DEFINITION != 0 {
197                // Innermost definition with this name spanning the line.
198                if let Some(s) = defs_here
199                    .iter()
200                    .filter(|s| {
201                        s.name == name && s.range.start.line <= line && line <= s.range.end.line
202                    })
203                    .max_by_key(|s| s.range.start.line)
204                {
205                    sf.defs.push((occ.symbol.clone(), s.id.render()));
206                    out.definitions += 1;
207                }
208            } else {
209                sf.refs.push(ScipRef {
210                    line,
211                    name,
212                    symbol: occ.symbol.clone(),
213                });
214                out.references += 1;
215            }
216        }
217    }
218    Ok(out)
219}
220
221/// The last descriptor's name: `npm pkg 1.0 src/`a.ts`/Foo#bar().` → `bar`.
222fn last_name(symbol: &str) -> Option<String> {
223    let s = scip::symbol::parse_symbol(symbol).ok()?;
224    s.descriptors.last().map(|d| d.name.clone())
225}