Files
rustytokens/src/index/pipeline.rs
T
endergebnisandCopilot 6875998ab6 Fix crate-name import resolution for bin+lib crates
Register the Cargo package name (normalized - -> _) as an alias for
src/lib.rs in the resolver, so 'use <crate>::{...}' in src/main.rs
resolves to src/lib.rs. Adds rustdemo/Cargo.toml fixture and test
coverage; updates blast-radius test for the new transitive edge.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-04 03:07:47 +02:00

236 lines
7.4 KiB
Rust

//! Index pipeline: hash, run ctags, extract imports, write to SQLite in one transaction.
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use rayon::prelude::*;
use rusqlite::params;
use crate::ctags;
use crate::discovery::{self, SourceFile};
use crate::index::{hashing, imports, store};
#[derive(Debug, serde::Serialize)]
pub struct IndexStats {
pub files: i64,
pub symbols: i64,
pub imports: i64,
}
/// Read the `[package] name` from `Cargo.toml`, normalized to the crate
/// identifier (`-` → `_`) used in `use <crate>::…`.
fn read_crate_name(project: &Path) -> Option<String> {
let toml = std::fs::read_to_string(project.join("Cargo.toml")).ok()?;
let mut in_package = false;
for line in toml.lines() {
let l = line.trim();
if l.starts_with('[') {
in_package = l == "[package]";
continue;
}
if in_package
&& let Some(v) = l.strip_prefix("name").map(str::trim)
&& let Some(v) = v.strip_prefix('=').map(str::trim)
{
let name = v.trim_matches('"');
if !name.is_empty() {
return Some(name.replace('-', "_"));
}
}
}
None
}
/// (Re)index a project. `recreate` drops any existing index first.
pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats> {
if recreate && db_path.exists() {
std::fs::remove_file(db_path).context("remove old index")?;
}
let conn = store::open(db_path)?;
let files = discovery::discover(project)?;
let existing: HashMap<String, (i64, String)> = store::all_files(&conn)?
.into_iter()
.map(|f| (f.path.clone(), (f.id, f.content_hash.clone())))
.collect();
// Hash all files in parallel.
let hashed: Vec<(String, String, i64, &'static str)> = files
.par_iter()
.map(|f| {
let h = hashing::hash_file(&f.path).unwrap_or_else(|_| "0000000000000000".to_string());
let m = hashing::mtime_secs(&f.path);
(f.rel.clone(), h, m, f.language)
})
.collect();
let by_rel: HashMap<&str, &SourceFile> = files.iter().map(|f| (f.rel.as_str(), f)).collect();
let abs_to_rel: HashMap<String, String> = files
.iter()
.map(|f| (f.path.to_string_lossy().into_owned(), f.rel.clone()))
.collect();
let changed: Vec<&SourceFile> = hashed
.iter()
.filter(|(rel, h, _, _)| existing.get(rel).map(|(_, oh)| oh != h).unwrap_or(true))
.filter_map(|(rel, ..)| by_rel.get(rel.as_str()).copied())
.collect();
let deleted_ids: Vec<i64> = existing
.iter()
.filter(|(rel, _)| !by_rel.contains_key(rel.as_str()))
.map(|(_, (id, _))| *id)
.collect();
conn.execute_batch("BEGIN;")?;
let mut file_ids: HashMap<String, i64> = HashMap::new();
for (rel, h, m, lang) in &hashed {
let id = store::upsert_file(&conn, rel, h, lang, *m)?;
file_ids.insert(rel.clone(), id);
}
for id in &deleted_ids {
store::delete_file(&conn, *id)?;
conn.execute("DELETE FROM content_fts WHERE rowid = ?1", [id])?;
}
// Run ctags once over changed files.
let changed_paths: Vec<&Path> = changed.iter().map(|f| f.path.as_path()).collect();
let tags = ctags::run(&changed_paths)?;
let mut tags_by_rel: HashMap<String, Vec<ctags::Tag>> = HashMap::new();
for t in tags {
if let Some(rel) = abs_to_rel.get(&t.path) {
tags_by_rel.entry(rel.clone()).or_default().push(t);
}
}
let resolver = imports::Resolver::build(
file_ids.iter().map(|(k, v)| (k.clone(), *v)).collect(),
read_crate_name(project).as_deref(),
);
for f in &changed {
let fid = file_ids[&f.rel];
let content = std::fs::read_to_string(&f.path).unwrap_or_default();
let tags = tags_by_rel.get(&f.rel).cloned().unwrap_or_default();
store::replace_symbols(&conn, fid, &tags_to_symbols(&tags, &content))?;
store::clear_imports_for_file(&conn, fid)?;
let dir = f
.rel
.rsplit_once('/')
.map(|(d, _)| d)
.unwrap_or("")
.to_string();
for imp in imports::extract(f) {
if let Some(to) = resolver.resolve_with_context(&imp.target, &dir, imp.levels)
&& to != fid
{
store::insert_import(&conn, fid, to, &imp.import_type)?;
}
}
conn.execute("DELETE FROM content_fts WHERE rowid = ?1", [fid])?;
conn.execute(
"INSERT INTO content_fts(rowid, path, content) VALUES (?1, ?2, ?3)",
params![fid, f.rel, content],
)?;
}
conn.execute_batch("COMMIT;")?;
let (files_n, symbols_n, imports_n) = store::stats(&conn)?;
Ok(IndexStats {
files: files_n,
symbols: symbols_n,
imports: imports_n,
})
}
fn tags_to_symbols(tags: &[ctags::Tag], content: &str) -> Vec<store::NewSymbol> {
let lines: Vec<&str> = content.lines().collect();
tags.iter()
.filter_map(|t| {
let line = t.line? as i64;
let end = t.end.map(|e| e as i64).unwrap_or(line);
let kind = t.kind.clone().unwrap_or_default();
let end = extend_block_end(&lines, line, end, &kind);
let name = t.name.clone();
let qualified_name = match &t.scope {
Some(s) if !name.starts_with(&format!("{s}.")) => format!("{s}.{name}"),
_ => name.clone(),
};
Some(store::NewSymbol {
name,
qualified_name,
kind,
signature: t.signature.clone(),
line_start: line,
line_end: end,
})
})
.collect()
}
/// ctags omits a body span for some kinds (e.g. Rust `fn`), leaving `end ==
/// start` even for a multi-line block. Extend brace-based blocks to their
/// closing brace so stored spans are correct for every reader.
fn extend_block_end(lines: &[&str], start: i64, end: i64, kind: &str) -> i64 {
if !is_block_kind(kind) || end > start {
return end;
}
// Semicolon-terminated declarations (`mod foo;`, `struct Foo;`) have no body.
if let Some(line) = lines.get((start - 1) as usize)
&& line.trim_end().ends_with(';')
{
return end;
}
let mut depth = 0i64;
let mut opened = false;
let mut out = end;
// ponytail: naive char brace count; braces in string/comment literals skew
// depth. Swap for a tokenizer only if real spans come back wrong.
for (i, line) in lines.iter().enumerate() {
let n = (i as i64) + 1;
if n < start {
continue;
}
for c in line.chars() {
match c {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
_ => {}
}
}
if opened {
out = n;
}
if opened && depth <= 0 {
break;
}
}
out
}
fn is_block_kind(kind: &str) -> bool {
matches!(
kind,
"function"
| "method"
| "struct"
| "class"
| "interface"
| "enum"
| "trait"
| "impl"
| "implementation"
| "module"
| "namespace"
| "union"
)
}