use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use protobuf::Message;
use scip::types::{Index, Occurrence, occurrence::Typed_range};
use crate::check::SymbolRef;
use crate::lang::Target;
use crate::scan::Dep;
const DEFINITION: i32 = 1;
#[derive(Debug, Clone)]
pub struct Reference {
pub line: usize,
pub symbol: String,
}
#[derive(Debug, Clone, Default)]
pub struct Document {
pub path: String,
pub references: Vec<Reference>,
pub definitions: Vec<String>,
}
#[derive(Debug, Default)]
pub struct Documents(pub Vec<Document>);
pub fn parse(bytes: &[u8], base: &Path) -> Result<Vec<Document>> {
let index = Index::parse_from_bytes(bytes).context("not a SCIP index")?;
let prefix = crate::scan::slash(base);
let mut documents = Vec::new();
for document in &index.documents {
let normalized = document.relative_path.replace('\\', "/");
let relative = normalized.as_str();
if relative.starts_with("..")
|| relative.starts_with('/')
|| relative.as_bytes().get(1) == Some(&b':')
{
continue;
}
let path = match prefix.as_str() {
"" => relative.to_string(),
base => format!("{base}/{relative}"),
};
let mut parsed = Document {
path,
..Document::default()
};
for occurrence in &document.occurrences {
if occurrence.symbol.is_empty() || occurrence.symbol.starts_with("local ") {
continue;
}
if occurrence.symbol_roles & DEFINITION != 0 {
parsed.definitions.push(occurrence.symbol.clone());
} else {
parsed.references.push(Reference {
line: line_of(occurrence) + 1,
symbol: occurrence.symbol.clone(),
});
}
}
documents.push(parsed);
}
Ok(documents)
}
fn line_of(occurrence: &Occurrence) -> usize {
let line = match &occurrence.typed_range {
Some(Typed_range::SingleLineRange(range)) => range.line,
Some(Typed_range::MultiLineRange(range)) => range.start_line,
Some(_) | None => occurrence.range.first().copied().unwrap_or(0),
};
usize::try_from(line).unwrap_or(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Namespace,
Type,
Other,
}
#[derive(Debug)]
struct Parsed<'a> {
scheme: &'a str,
package: &'a str,
descriptors: Vec<(String, Kind)>,
}
fn split(symbol: &str) -> Option<Parsed<'_>> {
let mut parts = symbol.splitn(5, ' ');
let scheme = parts.next()?;
let _manager = parts.next()?;
let package = parts.next()?;
let _version = parts.next()?;
let descriptors = descriptors(parts.next()?)?;
Some(Parsed {
scheme,
package,
descriptors,
})
}
fn descriptors(text: &str) -> Option<Vec<(String, Kind)>> {
scan(text).map(|found| {
found
.into_iter()
.map(|(name, kind, _)| (name, kind))
.collect()
})
}
fn scan(text: &str) -> Option<Vec<(String, Kind, usize)>> {
let chars: Vec<char> = text.chars().collect();
let mut at = 0;
let mut found = Vec::new();
while at < chars.len() {
let mut name = String::new();
if chars[at] == '`' {
at += 1;
loop {
match chars.get(at)? {
'`' if chars.get(at + 1) == Some(&'`') => {
name.push('`');
at += 2;
}
'`' => {
at += 1;
break;
}
c => {
name.push(*c);
at += 1;
}
}
}
} else if chars[at] == '[' || chars[at] == '(' {
} else {
while let Some(c) = chars.get(at)
&& (c.is_alphanumeric() || matches!(c, '_' | '+' | '-' | '$'))
{
name.push(*c);
at += 1;
}
}
let kind = match chars.get(at)? {
'/' => {
at += 1;
Kind::Namespace
}
'#' => {
at += 1;
Kind::Type
}
'.' | ':' | '!' => {
at += 1;
Kind::Other
}
'(' => {
let close = chars[at..].iter().position(|c| *c == ')')? + at;
at = close + 1;
if chars.get(at) == Some(&'.') {
at += 1;
}
Kind::Other
}
'[' => {
let close = chars[at..].iter().position(|c| *c == ']')? + at;
if name.is_empty() {
name = chars[at + 1..close].iter().collect();
}
at = close + 1;
Kind::Other
}
_ => return None,
};
found.push((name, kind, at));
}
Some(found)
}
fn class_symbol(symbol: &str) -> String {
let mut parts = symbol.splitn(5, ' ');
let header: Vec<&str> = parts.by_ref().take(4).collect();
let Some(text) = parts.next() else {
return symbol.to_string();
};
match scan(text).and_then(|d| d.into_iter().find(|(_, kind, _)| *kind == Kind::Type)) {
Some((_, _, end)) => {
let kept: String = text.chars().take(end).collect();
format!("{} {kept}", header.join(" "))
}
None => symbol.to_string(),
}
}
enum Referent {
Module,
Member(String),
Package,
}
impl Parsed<'_> {
fn referent(&self) -> Referent {
if self.scheme == "scip-python"
&& matches!(self.descriptors.as_slice(), [(_, Kind::Namespace), (name, _)] if name == "__init__")
{
return Referent::Module;
}
let class_based = matches!(self.scheme, "scip-php" | "scip-java" | "scip-dotnet");
if class_based
&& self
.descriptors
.iter()
.all(|(_, kind)| *kind == Kind::Namespace)
{
return Referent::Package;
}
if class_based
&& matches!(self.descriptors.last(), Some((_, Kind::Type)))
&& self.descriptors[..self.descriptors.len() - 1]
.iter()
.all(|(_, kind)| *kind == Kind::Namespace)
{
return Referent::Module;
}
match self
.descriptors
.iter()
.find(|(_, kind)| *kind != Kind::Namespace)
{
Some((name, _)) => Referent::Member(name.clone()),
None => Referent::Module,
}
}
fn namespace(&self, separator: &str) -> String {
self.descriptors
.iter()
.take_while(|(_, kind)| *kind == Kind::Namespace)
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(separator)
}
fn qualified(&self, separator: &str) -> String {
self.descriptors
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(separator)
}
fn owner(&self) -> String {
format!("{} {}", self.scheme, self.package)
}
fn external_module(&self) -> String {
match self.scheme {
"scip-go" => self.namespace("/"),
"scip-php" => self.qualified("\\"),
"scip-java" | "scip-dotnet" => self.qualified("."),
"scip-python" => self.namespace("."),
"rust-analyzer" => {
let krate = match self.package {
"core" | "alloc" | "std" => "std".to_string(),
other => other.replace('-', "_"),
};
std::iter::once(krate)
.chain(
self.descriptors
.iter()
.take_while(|(_, kind)| *kind == Kind::Namespace)
.map(|(name, _)| name.clone())
.filter(|name| name != "crate"),
)
.collect::<Vec<_>>()
.join("::")
}
_ => {
let mut parts: Vec<String> = self
.descriptors
.iter()
.take_while(|(_, kind)| *kind == Kind::Namespace)
.map(|(name, _)| name.clone())
.collect();
if let Some(last) = parts.last_mut() {
for suffix in [".d.ts", ".d.mts", ".d.cts", ".ts", ".tsx", ".js", ".mjs"] {
if let Some(stem) = last.strip_suffix(suffix) {
*last = stem.to_string();
break;
}
}
if last == "index" {
parts.pop();
}
}
std::iter::once(npm_name(self.package))
.chain(parts)
.collect::<Vec<_>>()
.join("/")
}
}
}
fn qualifier(&self, internal: Option<&str>) -> String {
match (self.scheme, internal) {
("scip-go", _) => self.namespace("/"),
("scip-python", _) => self.namespace("."),
("scip-php", _) => self.namespace("\\"),
("scip-java" | "scip-dotnet", _) => self.namespace("."),
("rust-analyzer", _) => self.external_module(),
(_, Some(path)) => path
.rsplit_once('.')
.map_or(path, |(stem, _)| stem)
.to_string(),
(_, None) => npm_name(self.package),
}
}
}
fn npm_name(package: &str) -> String {
match package.strip_prefix("@types/") {
Some(rest) => match rest.split_once("__") {
Some((scope, name)) => format!("@{scope}/{name}"),
None => rest.to_string(),
},
None => package.to_string(),
}
}
pub struct Facts {
pub deps: Vec<Dep>,
pub symbols: Vec<SymbolRef>,
}
pub fn facts(documents: &[Document], symbols: bool) -> Facts {
let mut defined: HashMap<&str, &str> = HashMap::new();
for document in documents {
for symbol in &document.definitions {
let entry = defined.entry(symbol).or_insert(&document.path);
if document.path.as_str() < *entry {
*entry = &document.path;
}
}
}
let own: BTreeSet<String> = defined
.keys()
.filter_map(|symbol| split(symbol).map(|parsed| parsed.owner()))
.collect();
let mut python_modules: Vec<(String, &str)> = Vec::new();
for (symbol, path) in &defined {
if let Some(parsed) = split(symbol)
&& parsed.scheme == "scip-python"
&& matches!(parsed.referent(), Referent::Module)
{
python_modules.push((parsed.namespace("."), path));
}
}
let python_module = |name: &str| -> Option<&str> {
let ending = format!(".{name}");
let mut found = python_modules
.iter()
.filter(|(module, _)| module == name || module.ends_with(&ending))
.map(|(_, path)| *path);
let first = found.next()?;
found.all(|path| path == first).then_some(first)
};
let mut deps = Vec::new();
let mut uses = Vec::new();
for document in documents {
let mut paths: HashMap<usize, Vec<Vec<String>>> = HashMap::new();
for reference in &document.references {
if let Some(parsed) = split(&reference.symbol)
&& parsed.scheme == "rust-analyzer"
&& matches!(parsed.referent(), Referent::Module)
{
paths
.entry(reference.line)
.or_default()
.push(parsed.descriptors.iter().map(|(n, _)| n.clone()).collect());
}
}
let shadowed = |parsed: &Parsed, line: usize| {
parsed.scheme == "rust-analyzer"
&& paths.get(&line).is_some_and(|all| {
let mine: Vec<String> =
parsed.descriptors.iter().map(|(n, _)| n.clone()).collect();
all.iter().any(|other| {
other.len() > mine.len() && (other.starts_with(&mine) || mine == ["crate"])
})
})
};
let mut first: BTreeMap<(Option<&str>, String), usize> = BTreeMap::new();
for reference in &document.references {
let symbol = if reference.symbol.starts_with("scip-php ")
|| reference.symbol.starts_with("scip-java ")
|| reference.symbol.starts_with("scip-dotnet ")
{
class_symbol(&reference.symbol)
} else {
reference.symbol.clone()
};
let Some(parsed) = split(&symbol) else {
continue;
};
let mut internal = defined.get(symbol.as_str()).copied();
match parsed.referent() {
Referent::Module => {
if shadowed(&parsed, reference.line) {
continue;
}
if internal.is_none() && parsed.scheme == "scip-python" {
internal = python_module(&parsed.namespace("."));
}
if internal == Some(document.path.as_str()) {
continue;
}
if internal.is_none()
&& parsed.scheme != "scip-dotnet"
&& own.contains(&parsed.owner())
{
continue;
}
let module = match internal {
Some(_) => String::new(),
None => parsed.external_module(),
};
if internal.is_none() && module.is_empty() {
continue;
}
let line = first.entry((internal, module)).or_insert(reference.line);
*line = (*line).min(reference.line);
}
Referent::Package => {}
Referent::Member(name) => {
if !symbols {
continue;
}
let home = internal;
if home == Some(document.path.as_str()) {
continue;
}
let target = match home {
Some(path) => Target::Internal(PathBuf::from(path)),
None => Target::External(parsed.external_module()),
};
uses.push(SymbolRef {
file: PathBuf::from(&document.path),
line: reference.line,
specifier: parsed.qualifier(home),
name,
target,
});
}
}
}
for ((internal, module), line) in first {
let (specifier, target) = match internal {
Some(path) => (path.to_string(), Target::Internal(PathBuf::from(path))),
None => (module.clone(), Target::External(module)),
};
deps.push(Dep {
file: PathBuf::from(&document.path),
line,
specifier,
target,
});
}
}
Facts {
deps,
symbols: uses,
}
}
impl Documents {
pub fn merged(parts: Vec<Vec<Document>>) -> Result<Self> {
let mut by_path: BTreeMap<String, Document> = BTreeMap::new();
for document in parts.into_iter().flatten() {
match by_path.get_mut(&document.path) {
Some(seen) => {
seen.references.extend(document.references);
seen.definitions.extend(document.definitions);
}
None => {
by_path.insert(document.path.clone(), document);
}
}
}
if by_path.is_empty() {
bail!("the indexers produced no documents");
}
Ok(Self(by_path.into_values().collect()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(path: &str, defs: &[&str], refs: &[(usize, &str)]) -> Document {
Document {
path: path.to_string(),
definitions: defs.iter().map(|s| s.to_string()).collect(),
references: refs
.iter()
.map(|(line, symbol)| Reference {
line: *line,
symbol: symbol.to_string(),
})
.collect(),
}
}
const DB: &str = "scip-go gomod example.com/app . `example.com/app/internal/db`/";
const FMT: &str = "scip-go gomod github.com/golang/go/src go1.25 fmt/";
#[test]
fn parses_descriptors() {
let d = descriptors("src/lib/`index.ts`/Client#Query().").unwrap();
let names: Vec<_> = d.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["src", "lib", "index.ts", "Client", "Query"]);
assert_eq!(d[2].1, Kind::Namespace);
assert_eq!(d[3].1, Kind::Type);
assert_eq!(d[4].1, Kind::Other);
let go = split("scip-go gomod example.com/app . `example.com/app/internal/db`/Scoped().")
.unwrap();
assert_eq!(go.namespace("/"), "example.com/app/internal/db");
assert!(matches!(go.referent(), Referent::Member(n) if n == "Scoped"));
assert!(matches!(split(DB).unwrap().referent(), Referent::Module));
}
#[test]
fn module_references_are_dependencies_at_their_first_line() {
let documents = vec![
doc("internal/db/db.go", &[DB, &format!("{DB}Client#")], &[]),
doc(
"internal/handlers/h.go",
&[],
&[
(12, DB),
(6, DB),
(6, FMT),
(9, &format!("{DB}Client#")),
(
9,
"scip-go gomod github.com/golang/go/src go1.25 fmt/Println().",
),
],
),
];
let facts = facts(&documents, false);
let got: Vec<_> = facts
.deps
.iter()
.map(|d| (d.file.to_str().unwrap(), d.line, d.target.clone()))
.collect();
assert_eq!(
got,
vec![
("internal/handlers/h.go", 6, Target::External("fmt".into())),
(
"internal/handlers/h.go",
6,
Target::Internal(PathBuf::from("internal/db/db.go"))
),
]
);
}
#[test]
fn member_references_are_symbol_uses() {
let documents = vec![
doc("internal/db/db.go", &[DB, &format!("{DB}Client#")], &[]),
doc(
"internal/handlers/h.go",
&[],
&[
(9, &format!("{DB}Client#")),
(10, &format!("{FMT}Println().")),
],
),
];
let uses = facts(&documents, true).symbols;
assert_eq!(uses.len(), 2);
assert_eq!(uses[0].name, "Client");
assert_eq!(uses[0].specifier, "example.com/app/internal/db");
assert_eq!(
uses[0].target,
Target::Internal(PathBuf::from("internal/db/db.go"))
);
assert_eq!(uses[1].target, Target::External("fmt".into()));
assert!(facts(&documents, false).symbols.is_empty());
}
#[test]
fn a_class_is_the_module_in_php() {
let documents = vec![
doc(
"src/Domain/Order.php",
&["scip-php composer __root__ 1.0 App/Domain/Order#"],
&[],
),
doc(
"src/Web/C.php",
&[],
&[
(4, "scip-php composer __root__ 1.0 App/Domain/Order#"),
(
5,
"scip-php composer illuminate/support 10.0 Illuminate/Support/Str#",
),
(7, "scip-php composer __root__ 1.0 App/Domain/Order#id()."),
],
),
];
let deps = facts(&documents, false).deps;
let got: Vec<_> = deps.iter().map(|d| (d.line, d.target.clone())).collect();
assert_eq!(
got,
vec![
(5, Target::External("Illuminate\\Support\\Str".into())),
(4, Target::Internal(PathBuf::from("src/Domain/Order.php"))),
]
);
}
#[test]
fn typescript_externals_use_the_npm_name() {
assert_eq!(npm_name("@types/lodash"), "lodash");
assert_eq!(npm_name("@types/babel__core"), "@babel/core");
assert_eq!(npm_name("react"), "react");
let documents = vec![doc(
"src/a.ts",
&[],
&[(1, "scip-typescript npm @types/lodash 4.0.0 `index.d.ts`/")],
)];
let facts = facts(&documents, false);
assert_eq!(facts.deps[0].target, Target::External("lodash".into()));
let deep = split("scip-typescript npm moment 2.0.0 locale/`ru.d.ts`/").unwrap();
assert_eq!(deep.external_module(), "moment/locale/ru");
}
#[test]
fn documents_outside_the_project_are_dropped_and_paths_rebased() {
let mut index = Index::new();
for path in ["a.go", "../../go-build/x.go", "C:\\cache\\y.go", "b\\c.go"] {
let mut document = scip::types::Document::new();
document.relative_path = path.to_string();
let mut occurrence = Occurrence::new();
occurrence.symbol = DB.to_string();
occurrence.typed_range =
Some(Typed_range::SingleLineRange(scip::types::SingleLineRange {
line: 4,
..Default::default()
}));
document.occurrences.push(occurrence);
index.documents.push(document);
}
let bytes = index.write_to_bytes().unwrap();
let documents = parse(&bytes, Path::new("services/api")).unwrap();
let paths: Vec<_> = documents.iter().map(|d| d.path.as_str()).collect();
assert_eq!(paths, ["services/api/a.go", "services/api/b/c.go"]);
assert_eq!(documents[0].references[0].line, 5);
}
}