1use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11use sha2::{Digest, Sha256};
12
13use crate::ToolchainFingerprint;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct LeanModuleDescriptor {
18 pub module: String,
19 pub path: PathBuf,
20 pub source_root: String,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct LeanModuleSetFingerprint {
26 pub toolchain: ToolchainFingerprint,
27 pub lakefile_sha256: String,
28 pub manifest_sha256: Option<String>,
29 pub source_count: u64,
30 pub source_max_mtime_ns: u128,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct LeanLakeProjectModules {
36 pub requested_root: PathBuf,
37 pub project_root: PathBuf,
38 pub lakefile: PathBuf,
39 pub module_roots: Vec<String>,
40 pub selected_roots: Vec<String>,
41 pub modules: Vec<LeanModuleDescriptor>,
42 pub fingerprint: LeanModuleSetFingerprint,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct LeanModuleDiscoveryOptions {
48 requested_root: PathBuf,
49 selected_roots: Option<Vec<String>>,
50 toolchain: ToolchainFingerprint,
51}
52
53impl LeanModuleDiscoveryOptions {
54 #[must_use]
56 pub fn new(requested_root: impl Into<PathBuf>) -> Self {
57 Self {
58 requested_root: requested_root.into(),
59 selected_roots: None,
60 toolchain: ToolchainFingerprint::current(),
61 }
62 }
63
64 #[must_use]
66 pub fn selected_roots(mut self, roots: impl IntoIterator<Item = impl Into<String>>) -> Self {
67 self.selected_roots = Some(roots.into_iter().map(Into::into).collect());
68 self
69 }
70
71 #[must_use]
76 pub fn toolchain(mut self, toolchain: ToolchainFingerprint) -> Self {
77 self.toolchain = toolchain;
78 self
79 }
80}
81
82#[derive(Debug)]
84pub enum LeanModuleDiscoveryDiagnostic {
85 MissingLakeRoot {
86 requested_root: PathBuf,
87 },
88 MissingModuleRoot {
89 project_root: PathBuf,
90 module_root: String,
91 },
92 InvalidModuleName {
93 module: String,
94 reason: String,
95 },
96 UnsupportedToolchain {
97 active: String,
98 supported_window: String,
99 },
100 Io {
101 path: PathBuf,
102 message: &'static str,
103 source: std::io::Error,
104 },
105}
106
107impl fmt::Display for LeanModuleDiscoveryDiagnostic {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 Self::MissingLakeRoot { requested_root } => {
111 write!(
112 f,
113 "lean-toolchain: no Lake project found at {} or {}/lean",
114 requested_root.display(),
115 requested_root.display()
116 )
117 }
118 Self::MissingModuleRoot {
119 project_root,
120 module_root,
121 } => {
122 write!(
123 f,
124 "lean-toolchain: module root `{module_root}` was not found in {}",
125 project_root.display()
126 )
127 }
128 Self::InvalidModuleName { module, reason } => {
129 write!(f, "lean-toolchain: invalid Lean module name `{module}`: {reason}")
130 }
131 Self::UnsupportedToolchain {
132 active,
133 supported_window,
134 } => {
135 write!(
136 f,
137 "lean-toolchain: active Lean toolchain {active} is not in the supported window: {supported_window}"
138 )
139 }
140 Self::Io { path, message, source } => {
141 write!(f, "lean-toolchain: {message} at {}: {source}", path.display())
142 }
143 }
144 }
145}
146
147impl std::error::Error for LeanModuleDiscoveryDiagnostic {
148 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
149 match self {
150 Self::Io { source, .. } => Some(source),
151 Self::MissingLakeRoot { .. }
152 | Self::MissingModuleRoot { .. }
153 | Self::InvalidModuleName { .. }
154 | Self::UnsupportedToolchain { .. } => None,
155 }
156 }
157}
158
159pub fn discover_lake_modules(
167 options: LeanModuleDiscoveryOptions,
168) -> Result<LeanLakeProjectModules, LeanModuleDiscoveryDiagnostic> {
169 if !options.toolchain.is_supported() {
170 return Err(LeanModuleDiscoveryDiagnostic::UnsupportedToolchain {
171 active: options.toolchain.lean_version.to_owned(),
172 supported_window: supported_window(),
173 });
174 }
175
176 let requested_root = normalize_existing(&options.requested_root)?;
177 let (project_root, lakefile) = lake_root_for(&requested_root)?;
178 let module_roots = discover_module_roots(&project_root, &lakefile)?;
179 let selected_roots = options.selected_roots.unwrap_or_else(|| module_roots.clone());
180 for root in &selected_roots {
181 validate_module_name(root)?;
182 if !module_root_exists(&project_root, root) {
183 return Err(LeanModuleDiscoveryDiagnostic::MissingModuleRoot {
184 project_root,
185 module_root: root.clone(),
186 });
187 }
188 }
189
190 let modules = enumerate_sources(&project_root, &selected_roots)?;
191 let fingerprint = fingerprint_source_set(&project_root, &lakefile, &modules, options.toolchain)?;
192 Ok(LeanLakeProjectModules {
193 requested_root,
194 project_root,
195 lakefile,
196 module_roots,
197 selected_roots,
198 modules,
199 fingerprint,
200 })
201}
202
203pub fn lake_target_declared(project_root: &Path, target_name: &str) -> Result<bool, LeanModuleDiscoveryDiagnostic> {
209 let requested_root = normalize_existing(project_root)?;
210 let (_project_root, lakefile) = lake_root_for(&requested_root)?;
211 let contents = read_to_string(&lakefile, "could not read Lake file")?;
212 let quoted = format!("lean_lib «{target_name}»");
213 let bare = format!("lean_lib {target_name}");
214 let string = format!("lean_lib \"{target_name}\"");
215 Ok(contents.contains("ed) || contents.contains(&bare) || contents.contains(&string))
216}
217
218fn normalize_existing(path: &Path) -> Result<PathBuf, LeanModuleDiscoveryDiagnostic> {
219 let expanded = expand_home(path);
220 if !expanded.exists() {
221 return Err(LeanModuleDiscoveryDiagnostic::MissingLakeRoot {
222 requested_root: expanded,
223 });
224 }
225 fs::canonicalize(&expanded).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
226 path: expanded,
227 message: "could not canonicalize path",
228 source,
229 })
230}
231
232fn expand_home(path: &Path) -> PathBuf {
233 let Some(text) = path.to_str() else {
234 return path.to_path_buf();
235 };
236 if text == "~" {
237 return home_dir().unwrap_or_else(|| path.to_path_buf());
238 }
239 if let Some(rest) = text.strip_prefix("~/")
240 && let Some(home) = home_dir()
241 {
242 return home.join(rest);
243 }
244 path.to_path_buf()
245}
246
247fn home_dir() -> Option<PathBuf> {
248 std::env::var_os("HOME").map(PathBuf::from)
249}
250
251fn lake_root_for(path: &Path) -> Result<(PathBuf, PathBuf), LeanModuleDiscoveryDiagnostic> {
252 if let Some(lakefile) = lakefile_path(path) {
253 return Ok((path.to_path_buf(), lakefile));
254 }
255 let nested = path.join("lean");
256 if let Some(lakefile) = lakefile_path(&nested) {
257 return Ok((nested, lakefile));
258 }
259 Err(LeanModuleDiscoveryDiagnostic::MissingLakeRoot {
260 requested_root: path.to_path_buf(),
261 })
262}
263
264fn lakefile_path(root: &Path) -> Option<PathBuf> {
265 let toml = root.join("lakefile.toml");
266 if toml.is_file() {
267 return Some(toml);
268 }
269 let lean = root.join("lakefile.lean");
270 lean.is_file().then_some(lean)
271}
272
273fn discover_module_roots(project_root: &Path, lakefile: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
274 let mut roots = if lakefile.file_name().and_then(|name| name.to_str()) == Some("lakefile.toml") {
275 discover_toml_lakefile_roots(lakefile)?
276 } else {
277 discover_lean_lakefile_roots(lakefile)?
278 };
279 if roots.is_empty() {
280 roots = discover_top_level_roots(project_root)?;
281 }
282 roots.sort();
283 roots.dedup();
284 for root in &roots {
285 validate_module_name(root)?;
286 }
287 Ok(roots)
288}
289
290fn discover_toml_lakefile_roots(lakefile: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
291 let text = read_to_string(lakefile, "could not read Lake TOML file")?;
292 let mut roots = Vec::new();
293 let mut in_lean_lib = false;
294 for line in text.lines() {
295 let trimmed = line.trim();
296 if trimmed == "[[lean_lib]]" {
297 in_lean_lib = true;
298 continue;
299 }
300 if trimmed.starts_with("[[") || trimmed.starts_with('[') {
301 in_lean_lib = false;
302 continue;
303 }
304 if in_lean_lib
305 && let Some(raw) = trimmed.strip_prefix("name")
306 && let Some((_eq, value)) = raw.split_once('=')
307 {
308 let root = normalize_lake_identifier(value);
309 if !root.is_empty() {
310 roots.push(root);
311 }
312 }
313 }
314 Ok(roots)
315}
316
317fn discover_lean_lakefile_roots(lakefile: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
318 let text = read_to_string(lakefile, "could not read Lake file")?;
319 Ok(text
320 .lines()
321 .filter_map(|line| {
322 let trimmed = line.trim_start();
323 let rest = trimmed.strip_prefix("lean_lib ")?;
324 let raw = rest.split_whitespace().next()?;
325 let root = normalize_lake_identifier(raw);
326 (!root.is_empty()).then_some(root)
327 })
328 .collect())
329}
330
331fn discover_top_level_roots(project_root: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
332 let mut roots = Vec::new();
333 for entry in fs::read_dir(project_root).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
334 path: project_root.to_path_buf(),
335 message: "could not read Lake project directory",
336 source,
337 })? {
338 let entry = entry.map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
339 path: project_root.to_path_buf(),
340 message: "could not read Lake project directory entry",
341 source,
342 })?;
343 let path = entry.path();
344 if path.extension().and_then(|extension| extension.to_str()) == Some("lean")
345 && let Some(stem) = path.file_stem().and_then(|stem| stem.to_str())
346 {
347 roots.push(stem.to_owned());
348 }
349 }
350 Ok(roots)
351}
352
353fn module_root_exists(project_root: &Path, module_root: &str) -> bool {
354 module_to_file(project_root, module_root).is_file() || module_to_dir(project_root, module_root).is_dir()
355}
356
357fn enumerate_sources(
358 project_root: &Path,
359 selected_roots: &[String],
360) -> Result<Vec<LeanModuleDescriptor>, LeanModuleDiscoveryDiagnostic> {
361 let mut modules = std::collections::BTreeMap::<String, LeanModuleDescriptor>::new();
362 for source_root in selected_roots {
363 let root_file = module_to_file(project_root, source_root);
364 if root_file.is_file() {
365 modules.insert(
366 source_root.clone(),
367 LeanModuleDescriptor {
368 module: source_root.clone(),
369 path: root_file,
370 source_root: source_root.clone(),
371 },
372 );
373 }
374
375 let module_dir = module_to_dir(project_root, source_root);
376 if module_dir.is_dir() {
377 collect_sources(project_root, &module_dir, source_root, &mut modules)?;
378 }
379 }
380 Ok(modules.into_values().collect())
381}
382
383fn collect_sources(
384 project_root: &Path,
385 dir: &Path,
386 source_root: &str,
387 modules: &mut std::collections::BTreeMap<String, LeanModuleDescriptor>,
388) -> Result<(), LeanModuleDiscoveryDiagnostic> {
389 for entry in fs::read_dir(dir).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
390 path: dir.to_path_buf(),
391 message: "could not read Lean source directory",
392 source,
393 })? {
394 let entry = entry.map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
395 path: dir.to_path_buf(),
396 message: "could not read Lean source directory entry",
397 source,
398 })?;
399 let path = entry.path();
400 let metadata = entry.metadata().map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
401 path: path.clone(),
402 message: "could not stat Lean source path",
403 source,
404 })?;
405 if metadata.is_dir() {
406 collect_sources(project_root, &path, source_root, modules)?;
407 } else if path.extension().and_then(|extension| extension.to_str()) == Some("lean") {
408 let module = module_from_path(project_root, &path)?;
409 validate_module_name(&module)?;
410 modules.insert(
411 module.clone(),
412 LeanModuleDescriptor {
413 module,
414 path,
415 source_root: source_root.to_owned(),
416 },
417 );
418 }
419 }
420 Ok(())
421}
422
423fn fingerprint_source_set(
424 project_root: &Path,
425 lakefile: &Path,
426 modules: &[LeanModuleDescriptor],
427 toolchain: ToolchainFingerprint,
428) -> Result<LeanModuleSetFingerprint, LeanModuleDiscoveryDiagnostic> {
429 let lakefile_sha256 = sha256_file(lakefile)?;
430 let manifest = project_root.join("lake-manifest.json");
431 let manifest_sha256 = if manifest.is_file() {
432 Some(sha256_file(&manifest)?)
433 } else {
434 None
435 };
436 let mut source_max_mtime_ns = 0_u128;
437 for module in modules {
438 let metadata = fs::metadata(&module.path).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
439 path: module.path.clone(),
440 message: "could not stat Lean module source",
441 source,
442 })?;
443 let modified = metadata
444 .modified()
445 .map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
446 path: module.path.clone(),
447 message: "could not read Lean module source mtime",
448 source,
449 })?;
450 let mtime_ns = modified
451 .duration_since(UNIX_EPOCH)
452 .map_or(0, |duration| duration.as_nanos());
453 source_max_mtime_ns = source_max_mtime_ns.max(mtime_ns);
454 }
455 Ok(LeanModuleSetFingerprint {
456 toolchain,
457 lakefile_sha256,
458 manifest_sha256,
459 source_count: modules.len() as u64,
460 source_max_mtime_ns,
461 })
462}
463
464fn sha256_file(path: &Path) -> Result<String, LeanModuleDiscoveryDiagnostic> {
465 fs::read(path)
466 .map(|bytes| sha256_hex(&bytes))
467 .map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
468 path: path.to_path_buf(),
469 message: "could not read file for fingerprinting",
470 source,
471 })
472}
473
474fn sha256_hex(bytes: &[u8]) -> String {
475 let mut hasher = Sha256::new();
476 hasher.update(bytes);
477 let digest = hasher.finalize();
478 let mut out = String::with_capacity(digest.len().saturating_mul(2));
479 for byte in digest {
480 use std::fmt::Write as _;
481 if write!(out, "{byte:02x}").is_err() {
482 return out;
483 }
484 }
485 out
486}
487
488fn module_from_path(project_root: &Path, path: &Path) -> Result<String, LeanModuleDiscoveryDiagnostic> {
489 let relative = path
490 .strip_prefix(project_root)
491 .map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
492 path: path.to_path_buf(),
493 message: "could not relativize Lean source path",
494 source: std::io::Error::other(source),
495 })?;
496 let mut parts: Vec<String> = relative
497 .components()
498 .map(|component| component.as_os_str().to_string_lossy().into_owned())
499 .collect();
500 if let Some(last) = parts.last_mut()
501 && let Some(stripped) = last.strip_suffix(".lean")
502 {
503 *last = stripped.to_owned();
504 }
505 Ok(parts.join("."))
506}
507
508fn module_to_file(project_root: &Path, module: &str) -> PathBuf {
509 let mut path = module_to_dir(project_root, module);
510 path.set_extension("lean");
511 path
512}
513
514fn module_to_dir(project_root: &Path, module: &str) -> PathBuf {
515 let mut path = project_root.to_path_buf();
516 for part in module.split('.') {
517 path.push(part);
518 }
519 path
520}
521
522fn normalize_lake_identifier(raw: &str) -> String {
523 raw.trim()
524 .trim_matches('`')
525 .trim_matches('«')
526 .trim_matches('»')
527 .trim_matches('"')
528 .trim()
529 .to_owned()
530}
531
532fn validate_module_name(module: &str) -> Result<(), LeanModuleDiscoveryDiagnostic> {
533 if module.is_empty() {
534 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
535 module: module.to_owned(),
536 reason: "module name is empty".to_owned(),
537 });
538 }
539 for component in module.split('.') {
540 if component.is_empty() {
541 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
542 module: module.to_owned(),
543 reason: "module name contains an empty component".to_owned(),
544 });
545 }
546 let mut chars = component.chars();
547 let Some(first) = chars.next() else {
548 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
549 module: module.to_owned(),
550 reason: "module name contains an empty component".to_owned(),
551 });
552 };
553 if !(first == '_' || first.is_alphabetic()) {
554 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
555 module: module.to_owned(),
556 reason: "module components must begin with a letter or underscore".to_owned(),
557 });
558 }
559 if chars.any(|ch| !(ch == '_' || ch == '\'' || ch.is_alphanumeric())) {
560 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
561 module: module.to_owned(),
562 reason: "module components may contain only letters, digits, underscores, or apostrophes".to_owned(),
563 });
564 }
565 }
566 Ok(())
567}
568
569fn read_to_string(path: &Path, message: &'static str) -> Result<String, LeanModuleDiscoveryDiagnostic> {
570 fs::read_to_string(path).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
571 path: path.to_path_buf(),
572 message,
573 source,
574 })
575}
576
577fn supported_window() -> String {
578 lean_rs_sys::SUPPORTED_TOOLCHAINS
579 .iter()
580 .map(|entry| format!("{:?}", entry.versions))
581 .collect::<Vec<_>>()
582 .join(", ")
583}
584
585#[cfg(test)]
586#[allow(
587 clippy::expect_used,
588 clippy::panic,
589 clippy::unwrap_used,
590 clippy::wildcard_enum_match_arm
591)]
592mod tests {
593 use super::{
594 LeanModuleDiscoveryDiagnostic, LeanModuleDiscoveryOptions, discover_lake_modules, lake_target_declared,
595 };
596 use crate::ToolchainFingerprint;
597 use std::fs;
598 use std::path::{Path, PathBuf};
599
600 #[test]
601 fn discovers_lean_lakefile_modules_deterministically() {
602 let root = temp_project("lean-lakefile");
603 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo where\n");
604 write_file(&root.join("Demo.lean"), "#check Nat\n");
605 fs::create_dir(root.join("Demo")).unwrap();
606 write_file(&root.join("Demo").join("B.lean"), "#check String\n");
607
608 let first = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
609 let second = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
610
611 assert_eq!(first.modules, second.modules);
612 assert_eq!(module_names(&first), vec!["Demo", "Demo.B"]);
613 assert_eq!(first.module_roots, vec!["Demo"]);
614 assert_eq!(first.fingerprint.source_count, 2);
615 }
616
617 #[test]
618 fn discovers_toml_lakefile_roots() {
619 let root = temp_project("toml-lakefile");
620 write_file(
621 &root.join("lakefile.toml"),
622 r#"
623name = "demo"
624[[lean_lib]]
625name = "Demo"
626[[lean_lib]]
627name = "Other"
628"#,
629 );
630 write_file(&root.join("Demo.lean"), "#check Nat\n");
631 write_file(&root.join("Other.lean"), "#check String\n");
632
633 let project = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
634 assert_eq!(project.module_roots, vec!["Demo", "Other"]);
635 assert_eq!(module_names(&project), vec!["Demo", "Other"]);
636 }
637
638 #[test]
639 fn missing_selected_root_is_typed() {
640 let root = temp_project("missing-root");
641 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo where\n");
642 write_file(&root.join("Demo.lean"), "#check Nat\n");
643
644 let err = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root).selected_roots(["Missing"]))
645 .expect_err("missing selected root should be typed");
646 match err {
647 LeanModuleDiscoveryDiagnostic::MissingModuleRoot { module_root, .. } => {
648 assert_eq!(module_root, "Missing");
649 }
650 other => panic!("expected missing module root, got {other:?}"),
651 }
652 }
653
654 #[test]
655 fn invalid_module_name_is_typed() {
656 let root = temp_project("invalid-module");
657 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo-Bad where\n");
658 write_file(&root.join("Demo-Bad.lean"), "#check Nat\n");
659
660 let err = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root))
661 .expect_err("invalid Lake module name should be typed");
662 match err {
663 LeanModuleDiscoveryDiagnostic::InvalidModuleName { module, .. } => {
664 assert_eq!(module, "Demo-Bad");
665 }
666 other => panic!("expected invalid module name, got {other:?}"),
667 }
668 }
669
670 #[test]
671 fn unsupported_toolchain_is_typed() {
672 let root = temp_project("unsupported-toolchain");
673 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo where\n");
674 write_file(&root.join("Demo.lean"), "#check Nat\n");
675 let mut fingerprint = ToolchainFingerprint::current();
676 fingerprint.lean_version = "0.0.0-test";
677
678 let err = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root).toolchain(fingerprint))
679 .expect_err("unsupported toolchain should be typed");
680 assert!(matches!(
681 err,
682 LeanModuleDiscoveryDiagnostic::UnsupportedToolchain { .. }
683 ));
684 }
685
686 #[test]
687 fn target_declaration_probe_is_typed() {
688 let root = temp_project("target-probe");
689 write_file(
690 &root.join("lakefile.lean"),
691 "package demo\nlean_lib Demo where\nlean_lib Extra where\n",
692 );
693 assert!(lake_target_declared(&root, "Demo").unwrap());
694 assert!(!lake_target_declared(&root, "Missing").unwrap());
695 }
696
697 fn module_names(project: &super::LeanLakeProjectModules) -> Vec<&str> {
698 project.modules.iter().map(|module| module.module.as_str()).collect()
699 }
700
701 fn temp_project(name: &str) -> PathBuf {
702 let root = std::env::temp_dir().join(format!("lean-toolchain-modules-{name}-{}", std::process::id()));
703 if root.exists() {
704 fs::remove_dir_all(&root).unwrap();
705 }
706 fs::create_dir_all(&root).unwrap();
707 root
708 }
709
710 fn write_file(path: &Path, contents: &str) {
711 if let Some(parent) = path.parent() {
712 fs::create_dir_all(parent).unwrap();
713 }
714 fs::write(path, contents).unwrap();
715 }
716}