use super::super::syntax::{LexicalRustSyntaxAdapter, RaRustSyntaxAdapter, RustSyntaxAdapter};
use super::model::RustIndex;
use crate::analysis::seam_cache::{
CacheLoad, FileFactCacheStats, RepoFileFactCache, RepoFileFactCacheKey,
};
use std::path::{Path, PathBuf};
pub fn build_index(root: &Path, files: &[PathBuf]) -> Result<RustIndex, String> {
build_index_with_adapters(root, files, &RaRustSyntaxAdapter, &LexicalRustSyntaxAdapter)
}
pub(crate) struct CachedRustIndex {
pub(crate) index: RustIndex,
pub(crate) file_fact_cache: FileFactCacheStats,
}
pub(crate) fn build_index_from_loaded_files_with_cache(
root: &Path,
files: &[(PathBuf, Vec<u8>)],
) -> Result<CachedRustIndex, String> {
build_index_from_loaded_files_with_cache_and_adapters(
root,
files,
&RaRustSyntaxAdapter,
&LexicalRustSyntaxAdapter,
)
}
fn build_index_from_loaded_files_with_cache_and_adapters(
root: &Path,
files: &[(PathBuf, Vec<u8>)],
adapter: &dyn RustSyntaxAdapter,
fallback: &dyn RustSyntaxAdapter,
) -> Result<CachedRustIndex, String> {
let cache = RepoFileFactCache::at(root);
let mut stats = FileFactCacheStats::default();
let mut index = RustIndex::default();
for (file, bytes) in files {
let key = RepoFileFactCacheKey::new(file, bytes);
let summary = match cache.load_file_facts(&key) {
CacheLoad::Hit(facts) => {
stats.hits += 1;
facts
}
CacheLoad::Miss => {
stats.misses += 1;
let facts = summarize_loaded_file(root, file, bytes, adapter, fallback)?;
match cache.store_file_facts(&key, &facts) {
Ok(()) => stats.stores += 1,
Err(_) => stats.store_errors += 1,
}
facts
}
CacheLoad::CorruptIgnored { reason } => {
stats.corrupt_ignored += 1;
eprintln!("ripr: repo file fact cache entry ignored ({reason})");
let facts = summarize_loaded_file(root, file, bytes, adapter, fallback)?;
match cache.store_file_facts(&key, &facts) {
Ok(()) => stats.stores += 1,
Err(_) => stats.store_errors += 1,
}
facts
}
};
insert_file_summary(&mut index, file.clone(), summary);
}
Ok(CachedRustIndex {
index,
file_fact_cache: stats,
})
}
fn build_index_with_adapters(
root: &Path,
files: &[PathBuf],
adapter: &dyn RustSyntaxAdapter,
fallback: &dyn RustSyntaxAdapter,
) -> Result<RustIndex, String> {
let mut index = RustIndex::default();
for file in files {
let full = root.join(file);
let text = std::fs::read_to_string(&full)
.map_err(|err| format!("failed to read {}: {err}", full.display()))?;
let summary = summarize_file_with_adapters(file, &text, adapter, fallback)?;
insert_file_summary(&mut index, file.clone(), summary);
}
Ok(index)
}
fn summarize_loaded_file(
root: &Path,
file: &Path,
bytes: &[u8],
adapter: &dyn RustSyntaxAdapter,
fallback: &dyn RustSyntaxAdapter,
) -> Result<super::FileFacts, String> {
let text = std::str::from_utf8(bytes)
.map_err(|err| format!("failed to read {}: {err}", root.join(file).display()))?;
summarize_file_with_adapters(file, text, adapter, fallback)
}
fn summarize_file_with_adapters(
file: &Path,
text: &str,
adapter: &dyn RustSyntaxAdapter,
fallback: &dyn RustSyntaxAdapter,
) -> Result<super::FileFacts, String> {
adapter
.summarize_file(file, text)
.or_else(|_| fallback.summarize_file(file, text))
}
fn insert_file_summary(index: &mut RustIndex, file: PathBuf, summary: super::FileFacts) {
index.tests.extend(summary.tests.clone());
index.functions.extend(summary.functions.clone());
index.files.insert(file, summary);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::syntax::{SyntaxNodeFact, TextRange};
use std::error::Error;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(name: &str) -> Result<PathBuf, Box<dyn Error>> {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let dir = std::env::temp_dir().join(format!("ripr-{name}-{stamp}"));
fs::create_dir_all(&dir)?;
Ok(dir)
}
fn write_manifest(root: &Path) -> Result<(), Box<dyn Error>> {
fs::write(
root.join("Cargo.toml"),
"[package]\nname='test'\nversion='0.1.0'\nedition='2024'\n",
)?;
Ok(())
}
#[test]
fn build_index_collects_functions_and_tests_from_workspace_files() -> Result<(), Box<dyn Error>>
{
let root = temp_dir("index_functions")?;
fs::create_dir_all(root.join("src"))?;
write_manifest(&root)?;
fs::write(
root.join("src/lib.rs"),
r#"
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
"#,
)?;
let index = build_index(&root, &[PathBuf::from("src/lib.rs")])?;
assert!(!index.functions.is_empty());
assert!(!index.tests.is_empty());
assert!(index.files.contains_key(&PathBuf::from("src/lib.rs")));
Ok(())
}
#[test]
fn build_index_collects_calls_returns_literals() -> Result<(), Box<dyn Error>> {
let root = temp_dir("index_facts")?;
fs::create_dir_all(root.join("src"))?;
write_manifest(&root)?;
fs::write(
root.join("src/lib.rs"),
r#"
pub fn process() -> Result<i32, String> {
let value = some_fn();
Ok(42)
}
fn some_fn() -> i32 {
100
}
"#,
)?;
let index = build_index(&root, &[PathBuf::from("src/lib.rs")])?;
let file_facts = index.files.get(&PathBuf::from("src/lib.rs"));
assert!(file_facts.is_some());
assert!(file_facts.is_some_and(|facts| !facts.calls.is_empty()));
assert!(
index
.files
.get(&PathBuf::from("src/lib.rs"))
.is_some_and(|facts| !facts.returns.is_empty())
);
Ok(())
}
#[test]
fn build_index_collects_parser_probe_shapes_for_valid_source() -> Result<(), Box<dyn Error>> {
let root = temp_dir("index_probes")?;
fs::create_dir_all(root.join("src"))?;
write_manifest(&root)?;
fs::write(
root.join("src/lib.rs"),
r#"
pub fn check(x: i32) -> bool {
if x > 0 {
true
} else {
false
}
}
"#,
)?;
let index = build_index(&root, &[PathBuf::from("src/lib.rs")])?;
assert!(
index
.files
.get(&PathBuf::from("src/lib.rs"))
.is_some_and(|facts| !facts.probe_shapes.is_empty())
);
Ok(())
}
#[test]
fn build_index_returns_read_error_for_missing_file() -> Result<(), Box<dyn Error>> {
let root = temp_dir("index_missing")?;
fs::create_dir_all(root.join("src"))?;
let result = build_index(&root, &[PathBuf::from("src/nonexistent.rs")]);
assert!(matches!(result, Err(ref err) if err.contains("failed to read")));
Ok(())
}
#[derive(Clone, Debug, Default)]
struct FailingSyntaxAdapter;
impl RustSyntaxAdapter for FailingSyntaxAdapter {
fn summarize_file(
&self,
_path: &Path,
_text: &str,
) -> Result<super::super::FileFacts, String> {
Err("synthetic parser failure".to_string())
}
fn changed_nodes(
&self,
_facts: &super::super::FileFacts,
_ranges: &[TextRange],
) -> Vec<SyntaxNodeFact> {
Vec::new()
}
}
#[derive(Clone, Debug, Default)]
struct StubSyntaxAdapter;
impl RustSyntaxAdapter for StubSyntaxAdapter {
fn summarize_file(
&self,
path: &Path,
text: &str,
) -> Result<super::super::FileFacts, String> {
Ok(super::super::FileFacts {
path: path.to_path_buf(),
source: text.to_string(),
..super::super::FileFacts::default()
})
}
fn changed_nodes(
&self,
_facts: &super::super::FileFacts,
_ranges: &[TextRange],
) -> Vec<SyntaxNodeFact> {
Vec::new()
}
}
#[test]
fn build_index_falls_back_when_primary_adapter_errors() -> Result<(), Box<dyn Error>> {
let root = temp_dir("index_fallback")?;
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/lib.rs"), "pub fn fallback() {}\n")?;
let index = build_index_with_adapters(
&root,
&[PathBuf::from("src/lib.rs")],
&FailingSyntaxAdapter,
&StubSyntaxAdapter,
)?;
assert_eq!(
index
.files
.get(&PathBuf::from("src/lib.rs"))
.map_or("", |facts| facts.source.as_str()),
"pub fn fallback() {}\n"
);
assert!(
FailingSyntaxAdapter
.changed_nodes(&super::super::FileFacts::default(), &[])
.is_empty()
);
assert!(
StubSyntaxAdapter
.changed_nodes(&super::super::FileFacts::default(), &[])
.is_empty()
);
Ok(())
}
#[test]
fn build_index_from_loaded_files_reuses_warm_file_facts() -> Result<(), Box<dyn Error>> {
let root = temp_dir("index_file_fact_cache")?;
fs::create_dir_all(root.join("src"))?;
let file = PathBuf::from("src/lib.rs");
let bytes = b"pub fn cached(value: i32) -> bool { value >= 10 }\n".to_vec();
let files = [(file.clone(), bytes.clone())];
let cold = build_index_from_loaded_files_with_cache(&root, &files)?;
assert_eq!(cold.file_fact_cache.hits, 0);
assert_eq!(cold.file_fact_cache.misses, 1);
assert_eq!(cold.file_fact_cache.stores, 1);
assert!(cold.index.files.contains_key(&file));
assert!(!cold.index.functions.is_empty());
let warm = build_index_from_loaded_files_with_cache(&root, &files)?;
assert_eq!(warm.file_fact_cache.hits, 1);
assert_eq!(warm.file_fact_cache.misses, 0);
assert_eq!(warm.file_fact_cache.stores, 0);
assert_eq!(warm.index.files.get(&file), cold.index.files.get(&file));
Ok(())
}
#[test]
fn build_index_from_loaded_files_misses_when_content_changes() -> Result<(), Box<dyn Error>> {
let root = temp_dir("index_file_fact_cache_invalidate")?;
fs::create_dir_all(root.join("src"))?;
let file = PathBuf::from("src/lib.rs");
let first = [(file.clone(), b"pub fn cached() -> i32 { 1 }\n".to_vec())];
let second = [(file.clone(), b"pub fn cached() -> i32 { 2 }\n".to_vec())];
let _ = build_index_from_loaded_files_with_cache(&root, &first)?;
let changed = build_index_from_loaded_files_with_cache(&root, &second)?;
assert_eq!(changed.file_fact_cache.hits, 0);
assert_eq!(changed.file_fact_cache.misses, 1);
assert_eq!(changed.file_fact_cache.stores, 1);
assert!(
changed
.index
.files
.get(&file)
.is_some_and(|facts| facts.source.contains("{ 2 }"))
);
Ok(())
}
}