use std::fmt;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::de::{self, Deserializer, SeqAccess, Visitor};
use walkdir::WalkDir;
const SUPPORTED_VERSION: u64 = 5;
const BUILD_LIB_REL: &str = ".lake/build/lib/lean";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefKind {
Def,
Ref,
}
#[derive(Debug, Clone)]
pub(crate) struct ReferenceLocation {
pub file: PathBuf,
pub start_line: u32,
pub start_column: u32,
pub end_line: u32,
pub end_column: u32,
pub kind: RefKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IndexStatus {
NotBuilt,
Present,
}
#[derive(Debug, Clone)]
pub(crate) struct ReferenceIndex {
pub status: IndexStatus,
pub references: Vec<ReferenceLocation>,
pub modules_scanned: usize,
pub modules_skipped: usize,
pub stale_sources: Vec<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum IleanError {
#[error("unsupported .ilean version {found} at {} (reader supports version {})", path.display(), SUPPORTED_VERSION)]
UnsupportedVersion { path: PathBuf, found: u64 },
#[error("read {}: {source}", path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parse {}: {source}", path.display())]
Json {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
pub(crate) fn references_to(project_root: &Path, name: &str) -> ReferenceIndex {
let build_dir = project_root.join(BUILD_LIB_REL);
if !build_dir.is_dir() {
return ReferenceIndex {
status: IndexStatus::NotBuilt,
references: Vec::new(),
modules_scanned: 0,
modules_skipped: 0,
stale_sources: Vec::new(),
};
}
let mut references: Vec<ReferenceLocation> = Vec::new();
let mut modules_scanned = 0usize;
let mut modules_skipped = 0usize;
let mut contributing: Vec<(PathBuf, PathBuf)> = Vec::new();
for entry in WalkDir::new(&build_dir).into_iter().filter_map(std::result::Result::ok) {
let index_path = entry.path();
if !entry.file_type().is_file() || index_path.extension().is_none_or(|ext| ext != "ilean") {
continue;
}
let Ok(module) = load(index_path) else {
modules_skipped = modules_skipped.saturating_add(1);
continue;
};
modules_scanned = modules_scanned.saturating_add(1);
let source = module_to_source(project_root, &module.module);
let before = references.len();
for (key, info) in &module.references {
if !key.contains(name) || !is_const_named(key, name) {
continue;
}
if let Some(definition) = &info.definition {
references.push(location_hit(&source, definition, RefKind::Def));
}
for usage in &info.usages {
references.push(location_hit(&source, usage, RefKind::Ref));
}
}
if references.len() > before {
contributing.push((source, index_path.to_path_buf()));
}
}
let stale_sources = collect_stale_sources(&contributing);
ReferenceIndex {
status: IndexStatus::Present,
references,
modules_scanned,
modules_skipped,
stale_sources,
}
}
fn load(path: &Path) -> Result<IleanRaw, IleanError> {
let bytes = std::fs::read(path).map_err(|source| IleanError::Io {
path: path.to_path_buf(),
source,
})?;
let probe: VersionProbe = serde_json::from_slice(&bytes).map_err(|source| IleanError::Json {
path: path.to_path_buf(),
source,
})?;
if probe.version != SUPPORTED_VERSION {
return Err(IleanError::UnsupportedVersion {
path: path.to_path_buf(),
found: probe.version,
});
}
serde_json::from_slice(&bytes).map_err(|source| IleanError::Json {
path: path.to_path_buf(),
source,
})
}
fn module_to_source(root: &Path, module: &str) -> PathBuf {
let relative: PathBuf = module.split('.').collect();
root.join(relative).with_extension("lean")
}
fn is_const_named(key: &str, name: &str) -> bool {
match serde_json::from_str::<RefIdentKey>(key) {
Ok(RefIdentKey::Const { n }) => n == name,
Ok(RefIdentKey::Fvar {}) | Err(_) => false,
}
}
fn location_hit(source: &Path, location: &LocationRaw, kind: RefKind) -> ReferenceLocation {
ReferenceLocation {
file: source.to_path_buf(),
start_line: location.start_line,
start_column: location.start_column,
end_line: location.end_line,
end_column: location.end_column,
kind,
}
}
fn collect_stale_sources(contributing: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
let mut stale = Vec::new();
for (source, index) in contributing {
if source_newer_than_index(source, index) {
stale.push(source.clone());
}
}
stale
}
fn source_newer_than_index(source: &Path, index: &Path) -> bool {
let Ok(source_mtime) = std::fs::metadata(source).and_then(|meta| meta.modified()) else {
return false;
};
let Ok(index_mtime) = std::fs::metadata(index).and_then(|meta| meta.modified()) else {
return false;
};
source_mtime > index_mtime
}
#[derive(Deserialize)]
struct VersionProbe {
version: u64,
}
#[derive(Deserialize)]
enum RefIdentKey {
#[serde(rename = "c")]
Const { n: String },
#[serde(rename = "f")]
Fvar {},
}
#[derive(Deserialize)]
struct IleanRaw {
module: String,
references: std::collections::HashMap<String, RefInfoRaw>,
}
#[derive(Deserialize)]
struct RefInfoRaw {
#[serde(default)]
definition: Option<LocationRaw>,
#[serde(default)]
usages: Vec<LocationRaw>,
}
struct LocationRaw {
start_line: u32,
start_column: u32,
end_line: u32,
end_column: u32,
}
impl<'de> Deserialize<'de> for LocationRaw {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct LocationVisitor;
impl<'de> Visitor<'de> for LocationVisitor {
type Value = LocationRaw;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a 4- or 5-element .ilean location array")
}
fn visit_seq<A>(self, mut seq: A) -> Result<LocationRaw, A::Error>
where
A: SeqAccess<'de>,
{
let start_line = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let start_column = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(1, &self))?;
let end_line = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(2, &self))?;
let end_column = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(3, &self))?;
let _parent_decl: Option<de::IgnoredAny> = seq.next_element()?;
if seq.next_element::<de::IgnoredAny>()?.is_some() {
return Err(de::Error::invalid_length(6, &self));
}
Ok(LocationRaw {
start_line,
start_column,
end_line,
end_column,
})
}
}
deserializer.deserialize_seq(LocationVisitor)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test code uses unwrap/expect/panic to surface failure paths concisely"
)]
mod tests {
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use super::*;
fn fixture(name: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ilean")
.join(name);
fs::read_to_string(&path).unwrap_or_else(|err| panic!("read fixture {}: {err}", path.display()))
}
fn stage(modules: &[(&str, &str)]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let build = tmp.path().join(BUILD_LIB_REL);
for (module, fixture_name) in modules {
let relative: PathBuf = module.split('.').collect();
let index = build.join(&relative).with_extension("ilean");
fs::create_dir_all(index.parent().unwrap()).unwrap();
fs::write(&index, fixture(fixture_name)).unwrap();
let source = tmp.path().join(&relative).with_extension("lean");
fs::create_dir_all(source.parent().unwrap()).unwrap();
fs::write(&source, "-- source stub\n").unwrap();
}
tmp
}
fn hit(index: &ReferenceIndex, file_suffix: &str, kind: RefKind) -> Vec<(u32, u32, u32, u32)> {
index
.references
.iter()
.filter(|reference| reference.kind == kind && reference.file.ends_with(file_suffix))
.map(|reference| {
(
reference.start_line,
reference.start_column,
reference.end_line,
reference.end_column,
)
})
.collect()
}
#[test]
fn resolves_def_and_usages_across_modules() {
let project = stage(&[("Demo.A", "demo_a.ilean"), ("Demo.B", "demo_b.ilean")]);
let index = references_to(project.path(), "Demo.A.foo");
assert_eq!(index.status, IndexStatus::Present);
assert_eq!(index.modules_scanned, 2);
assert_eq!(index.modules_skipped, 0);
assert_eq!(hit(&index, "Demo/A.lean", RefKind::Def), vec![(3, 4, 3, 7)]);
assert!(hit(&index, "Demo/A.lean", RefKind::Ref).is_empty());
assert!(hit(&index, "Demo/B.lean", RefKind::Def).is_empty());
let mut b_refs = hit(&index, "Demo/B.lean", RefKind::Ref);
b_refs.sort_unstable();
assert_eq!(b_refs, vec![(5, 2, 5, 5), (6, 8, 6, 11)]);
assert_eq!(index.references.len(), 3);
}
#[test]
fn ignores_fvar_keys_and_mismatched_consts() {
let project = stage(&[("Demo.B", "demo_b.ilean")]);
let index = references_to(project.path(), "Demo.A.foo");
assert_eq!(index.references.len(), 2);
assert!(index.references.iter().all(|reference| reference.kind == RefKind::Ref));
}
#[test]
fn unknown_version_is_rejected_with_typed_error() {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ilean/bad_version.ilean");
match load(&path) {
Err(IleanError::UnsupportedVersion { found, .. }) => assert_eq!(found, 99),
Err(other) => panic!("expected UnsupportedVersion, got error {other:?}"),
Ok(_) => panic!("expected UnsupportedVersion, got a successful load"),
}
}
#[test]
fn malformed_file_is_skipped_not_fatal() {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ilean/malformed.ilean");
assert!(matches!(load(&path), Err(IleanError::Json { .. })));
let project = stage(&[("Demo.A", "demo_a.ilean"), ("Demo.Bad", "malformed.ilean")]);
let index = references_to(project.path(), "Demo.A.foo");
assert_eq!(index.status, IndexStatus::Present);
assert_eq!(index.modules_scanned, 1);
assert_eq!(index.modules_skipped, 1);
assert_eq!(hit(&index, "Demo/A.lean", RefKind::Def), vec![(3, 4, 3, 7)]);
}
#[test]
fn null_definition_yields_only_usages() {
let project = stage(&[("Demo.B", "demo_b.ilean")]);
let index = references_to(project.path(), "Demo.A.foo");
assert!(index.references.iter().all(|reference| reference.kind == RefKind::Ref));
}
#[test]
fn location_array_arity_is_bounded() {
assert!(serde_json::from_str::<LocationRaw>("[1,2,3,4]").is_ok());
assert!(serde_json::from_str::<LocationRaw>("[1,2,3,4,\"D.bar\"]").is_ok());
assert!(serde_json::from_str::<LocationRaw>("[1,2,3]").is_err());
assert!(serde_json::from_str::<LocationRaw>("[1,2,3,4,\"D.bar\",9]").is_err());
assert!(serde_json::from_str::<LocationRaw>("[1,2,3,4,5,6,7]").is_err());
}
#[test]
fn unbuilt_project_reports_not_built() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("Demo")).unwrap();
fs::write(tmp.path().join("Demo/A.lean"), "-- no build\n").unwrap();
let index = references_to(tmp.path(), "Demo.A.foo");
assert_eq!(index.status, IndexStatus::NotBuilt);
assert!(index.references.is_empty());
assert_eq!(index.modules_scanned, 0);
}
#[test]
fn stale_source_is_flagged() {
let project = stage(&[("Demo.A", "demo_a.ilean")]);
std::thread::sleep(Duration::from_millis(20));
let source = project.path().join("Demo/A.lean");
fs::write(&source, "-- edited after build\n").unwrap();
let index = references_to(project.path(), "Demo.A.foo");
let stale: BTreeSet<_> = index.stale_sources.iter().collect();
assert!(
stale.contains(&source),
"expected {source:?} flagged stale, got {stale:?}"
);
}
#[test]
#[ignore = "requires a built project via LEAN_HOST_MCP_ILEAN_FIXTURE"]
fn timing_against_real_index() {
let Some(root) = std::env::var_os("LEAN_HOST_MCP_ILEAN_FIXTURE") else {
eprintln!("LEAN_HOST_MCP_ILEAN_FIXTURE unset; skipping");
return;
};
let name = std::env::var("LEAN_HOST_MCP_ILEAN_NAME").unwrap_or_else(|_| "Nat.add".to_owned());
let started = std::time::Instant::now();
let index = references_to(Path::new(&root), &name);
let elapsed = started.elapsed();
eprintln!(
"references_to({name}) -> {} hits, {} scanned, {} skipped, status {:?} in {elapsed:?}",
index.references.len(),
index.modules_scanned,
index.modules_skipped,
index.status,
);
}
#[test]
fn fresh_source_is_not_flagged() {
let project = stage(&[("Demo.A", "demo_a.ilean")]);
std::thread::sleep(Duration::from_millis(20));
let index_file = project.path().join(BUILD_LIB_REL).join("Demo/A.ilean");
fs::write(&index_file, fixture("demo_a.ilean")).unwrap();
let index = references_to(project.path(), "Demo.A.foo");
assert!(index.stale_sources.is_empty());
}
}