1use crate::mine::module_name;
37use crate::{
38 Diagnostic, LeanFile, Provenance, Result, TraceConfig, detect_declaration, discover_lean_files,
39 run_lean_file,
40};
41use camino::{Utf8Path, Utf8PathBuf};
42use serde::{Deserialize, Serialize};
43use std::process::Stdio;
44use std::time::Duration;
45use tokio::process::Command;
46use tokio::time;
47use tracing::{debug, warn};
48use uuid::Uuid;
49
50pub const AXIOM_WHITELIST: [&str; 3] = ["propext", "Classical.choice", "Quot.sound"];
55
56const MAX_IMPORTERS: usize = 16;
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "guard", rename_all = "snake_case")]
64pub enum RejectReason {
65 StatementChanged {
67 declaration: String,
69 before: String,
71 after: String,
73 },
74 StatementUnverifiable {
76 detail: String,
78 },
79 DisallowedAxiom {
81 declaration: String,
83 offending: Vec<String>,
85 axioms: Vec<String>,
87 },
88 AxiomCheckFailed {
90 declaration: String,
92 detail: String,
94 },
95 ReverseDepFailed {
97 module: String,
99 detail: String,
101 },
102 VacuousClaim {
104 detail: String,
106 },
107}
108
109#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
111#[serde(tag = "status", rename_all = "snake_case")]
112pub enum GuardStatus {
113 Passed,
115 Skipped {
117 note: String,
119 },
120 Rejected {
122 reason: RejectReason,
124 },
125}
126
127#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
129pub struct AcceptReport {
130 pub statement: GuardStatus,
132 pub axioms: GuardStatus,
134 pub reverse_dep: GuardStatus,
136 pub negative_control: GuardStatus,
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct AcceptOutcome {
143 pub accepted: bool,
145 pub report: AcceptReport,
147 pub reject_reason: Option<RejectReason>,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct NegativeControl {
158 pub negation_source: String,
160 pub negation_name: String,
162}
163
164#[derive(Clone, Debug)]
166pub struct AcceptRequest<'a> {
167 pub lake_root: &'a Utf8Path,
169 pub workspace_root: &'a Utf8Path,
171 pub target: &'a Utf8Path,
173 pub edit_line: u32,
175 pub patched_diagnostics: &'a [Diagnostic],
177 pub provenance: &'a Provenance,
179 pub timeout: Duration,
181 pub run_reverse_dep: bool,
183 pub negative_control: Option<&'a NegativeControl>,
185}
186
187pub async fn evaluate(request: &AcceptRequest<'_>) -> AcceptOutcome {
192 let not_eval = || GuardStatus::Skipped {
193 note: "not evaluated; an earlier guard rejected".to_owned(),
194 };
195
196 let statement = check_statement(request);
197 if let GuardStatus::Rejected { reason } = &statement {
198 let reason = reason.clone();
199 warn!(?reason, "accept predicate rejected on the statement guard");
200 return AcceptOutcome {
201 accepted: false,
202 report: AcceptReport {
203 statement,
204 axioms: not_eval(),
205 reverse_dep: not_eval(),
206 negative_control: not_eval(),
207 },
208 reject_reason: Some(reason),
209 };
210 }
211
212 let axioms = check_axioms(request).await;
213 let axiom_reject = match &axioms {
217 GuardStatus::Passed => None,
218 GuardStatus::Rejected { reason } => Some(reason.clone()),
219 GuardStatus::Skipped { note } => Some(RejectReason::AxiomCheckFailed {
220 declaration: "<unknown>".to_owned(),
221 detail: format!("axiom guard did not run ({note}); refusing to accept"),
222 }),
223 };
224 if let Some(reason) = axiom_reject {
225 warn!(?reason, "accept predicate rejected on the axiom guard");
226 return AcceptOutcome {
227 accepted: false,
228 report: AcceptReport {
229 statement,
230 axioms,
231 reverse_dep: not_eval(),
232 negative_control: not_eval(),
233 },
234 reject_reason: Some(reason),
235 };
236 }
237
238 let reverse_dep = if request.run_reverse_dep {
239 check_reverse_dep(request).await
240 } else {
241 GuardStatus::Skipped {
242 note: "reverse-dependency guard disabled".to_owned(),
243 }
244 };
245 if let GuardStatus::Rejected { reason } = &reverse_dep {
246 let reason = reason.clone();
247 warn!(
248 ?reason,
249 "accept predicate rejected on the reverse-dep guard"
250 );
251 return AcceptOutcome {
252 accepted: false,
253 report: AcceptReport {
254 statement,
255 axioms,
256 reverse_dep,
257 negative_control: not_eval(),
258 },
259 reject_reason: Some(reason),
260 };
261 }
262
263 let negative_control = check_negative_control(request.negative_control);
264 AcceptOutcome {
265 accepted: true,
266 report: AcceptReport {
267 statement,
268 axioms,
269 reverse_dep,
270 negative_control,
271 },
272 reject_reason: None,
273 }
274}
275
276#[derive(Clone, Debug, Eq, PartialEq)]
282enum StatementCheck {
283 Unchanged {
285 declaration: String,
287 },
288 Changed {
290 declaration: String,
292 before: String,
294 after: String,
296 },
297 Unverifiable {
299 detail: String,
301 },
302}
303
304fn check_statement(request: &AcceptRequest<'_>) -> GuardStatus {
306 let original_path = request.lake_root.join(request.target);
307 let patched_path = request.workspace_root.join(request.target);
308
309 let original = match std::fs::read_to_string(&original_path) {
310 Ok(text) => text,
311 Err(err) => {
312 return GuardStatus::Rejected {
313 reason: RejectReason::StatementUnverifiable {
314 detail: format!("reading original {original_path}: {err}"),
315 },
316 };
317 }
318 };
319 let patched = match std::fs::read_to_string(&patched_path) {
320 Ok(text) => text,
321 Err(err) => {
322 return GuardStatus::Rejected {
323 reason: RejectReason::StatementUnverifiable {
324 detail: format!("reading patched {patched_path}: {err}"),
325 },
326 };
327 }
328 };
329
330 match compare_statement(&original, &patched, request.edit_line) {
331 StatementCheck::Unchanged { .. } => GuardStatus::Passed,
332 StatementCheck::Changed {
333 declaration,
334 before,
335 after,
336 } => GuardStatus::Rejected {
337 reason: RejectReason::StatementChanged {
338 declaration,
339 before,
340 after,
341 },
342 },
343 StatementCheck::Unverifiable { detail } => GuardStatus::Rejected {
344 reason: RejectReason::StatementUnverifiable { detail },
345 },
346 }
347}
348
349fn compare_statement(original: &str, patched: &str, edit_line: u32) -> StatementCheck {
351 let before = match enclosing_signature(original, edit_line) {
352 Ok(pair) => pair,
353 Err(detail) => {
354 return StatementCheck::Unverifiable {
355 detail: format!("original: {detail}"),
356 };
357 }
358 };
359 let after = match enclosing_signature(patched, edit_line) {
360 Ok(pair) => pair,
361 Err(detail) => {
362 return StatementCheck::Unverifiable {
363 detail: format!("patched: {detail}"),
364 };
365 }
366 };
367
368 if before.1 == after.1 {
369 StatementCheck::Unchanged {
370 declaration: before.0,
371 }
372 } else {
373 StatementCheck::Changed {
374 declaration: before.0,
375 before: before.1,
376 after: after.1,
377 }
378 }
379}
380
381fn enclosing_signature(
383 source: &str,
384 edit_line: u32,
385) -> std::result::Result<(String, String), String> {
386 let lines: Vec<&str> = source.lines().collect();
387 let idx = edit_line.saturating_sub(1) as usize;
388 let decl = detect_declaration(&lines, idx)
389 .ok_or_else(|| format!("no enclosing declaration at line {edit_line}"))?;
390 let label = decl
391 .name
392 .clone()
393 .map_or_else(|| decl.kind.clone(), |name| format!("{} {name}", decl.kind));
394 Ok((label, statement_signature(&decl.source)))
395}
396
397fn statement_signature(decl_source: &str) -> String {
406 let bytes = decl_source.as_bytes();
407 let mut depth: i32 = 0;
408 let mut in_string = false;
409 let mut i = 0usize;
410 while i < bytes.len() {
411 let byte = bytes[i];
412 if in_string {
413 if byte == b'\\' {
414 i += 2;
415 continue;
416 }
417 if byte == b'"' {
418 in_string = false;
419 }
420 i += 1;
421 continue;
422 }
423 match byte {
424 b'"' => in_string = true,
425 b'(' | b'[' | b'{' => depth += 1,
426 b')' | b']' | b'}' => depth -= 1,
427 b':' if depth <= 0 && bytes.get(i + 1) == Some(&b'=') => {
428 return decl_source[..i].trim_end().to_owned();
429 }
430 _ => {}
431 }
432 i += 1;
433 }
434 decl_source.trim_end().to_owned()
435}
436
437async fn check_axioms(request: &AcceptRequest<'_>) -> GuardStatus {
444 let patched_path = request.workspace_root.join(request.target);
445 let patched = match std::fs::read_to_string(&patched_path) {
446 Ok(text) => text,
447 Err(err) => {
448 return GuardStatus::Rejected {
449 reason: RejectReason::AxiomCheckFailed {
450 declaration: "<unknown>".to_owned(),
451 detail: format!("reading patched {patched_path}: {err}"),
452 },
453 };
454 }
455 };
456 let lines: Vec<&str> = patched.lines().collect();
457 let decl = detect_declaration(&lines, request.edit_line.saturating_sub(1) as usize);
458 let label = decl.as_ref().map_or_else(
459 || "<unknown>".to_owned(),
460 |found| {
461 found.name.clone().map_or_else(
462 || found.kind.clone(),
463 |name| format!("{} {name}", found.kind),
464 )
465 },
466 );
467
468 if request
471 .patched_diagnostics
472 .iter()
473 .any(|diagnostic| message_mentions_sorry(&diagnostic.message))
474 {
475 return GuardStatus::Rejected {
476 reason: RejectReason::DisallowedAxiom {
477 declaration: label,
478 offending: vec!["sorryAx".to_owned()],
479 axioms: vec!["sorryAx".to_owned()],
480 },
481 };
482 }
483
484 let Some(decl) = decl else {
485 return GuardStatus::Rejected {
489 reason: RejectReason::AxiomCheckFailed {
490 declaration: label,
491 detail: "no enclosing declaration to read axioms from".to_owned(),
492 },
493 };
494 };
495
496 match print_axioms(request, &patched, &decl).await {
497 Ok(axioms) => {
498 let offending: Vec<String> = axioms
499 .iter()
500 .filter(|axiom| !AXIOM_WHITELIST.contains(&axiom.as_str()))
501 .cloned()
502 .collect();
503 if offending.is_empty() {
504 GuardStatus::Passed
505 } else {
506 GuardStatus::Rejected {
507 reason: RejectReason::DisallowedAxiom {
508 declaration: label,
509 offending,
510 axioms,
511 },
512 }
513 }
514 }
515 Err(detail) => GuardStatus::Rejected {
516 reason: RejectReason::AxiomCheckFailed {
517 declaration: label,
518 detail,
519 },
520 },
521 }
522}
523
524async fn print_axioms(
534 request: &AcceptRequest<'_>,
535 patched: &str,
536 decl: &crate::Declaration,
537) -> std::result::Result<Vec<String>, String> {
538 let nonce = Uuid::new_v4().simple().to_string();
539 let begin = format!("LAP_AXIOMS_BEGIN_{nonce}");
540 let end = format!("LAP_AXIOMS_END_{nonce}");
541
542 let (print_name, alias_decl) = match &decl.name {
544 Some(name) => (name.clone(), None),
545 None => {
546 let alias = format!("__lap_probe_{nonce}");
547 let source =
548 alias_anonymous_decl(&decl.source, &decl.kind, &alias).ok_or_else(|| {
549 format!(
550 "could not build a named probe for anonymous `{}`",
551 decl.kind
552 )
553 })?;
554 (alias, Some(source))
555 }
556 };
557
558 let probe_rel = probe_path(request.target);
559 let probe_abs = request.workspace_root.join(&probe_rel);
560
561 let mut probe_lines: Vec<String> = patched.lines().map(str::to_owned).collect();
562 let insert_at = (decl.end_line as usize).min(probe_lines.len());
563
564 let mut block: Vec<String> = Vec::new();
565 if let Some(alias_decl) = &alias_decl {
566 block.extend(alias_decl.lines().map(str::to_owned));
567 }
568 block.push(format!("#eval IO.println {begin:?}"));
571 block.push(format!("#print axioms {print_name}"));
572 block.push(format!("#eval IO.println {end:?}"));
573 for (offset, line) in block.into_iter().enumerate() {
574 probe_lines.insert(insert_at + offset, line);
575 }
576 let mut probe_content = probe_lines.join("\n");
577 probe_content.push('\n');
578
579 if let Err(err) = std::fs::write(&probe_abs, &probe_content) {
580 return Err(format!("writing axiom probe {probe_abs}: {err}"));
581 }
582
583 let mut config = TraceConfig::new(request.workspace_root.to_path_buf());
584 config.timeout = request.timeout;
585 config.include_warnings = true;
586 config.keep_raw_output = true;
587 let trace = run_lean_file(&config, request.provenance, LeanFile(probe_rel)).await;
588
589 let _ = std::fs::remove_file(&probe_abs);
590
591 let combined = format!(
592 "{}\n{}",
593 trace.stderr.unwrap_or_default(),
594 trace.stdout.unwrap_or_default()
595 );
596 let window = slice_between(&combined, &begin, &end).ok_or_else(|| {
597 format!("axiom probe produced no sentinel-bracketed output for `{print_name}`")
598 })?;
599 parse_axioms(window, &print_name)
600 .ok_or_else(|| format!("could not read `#print axioms {print_name}` output"))
601}
602
603fn slice_between<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
606 let start = text.find(begin)? + begin.len();
607 let rest = text.get(start..)?;
608 let stop = rest.find(end)?;
609 rest.get(..stop)
610}
611
612fn alias_anonymous_decl(source: &str, kind: &str, alias: &str) -> Option<String> {
620 let mut from = 0usize;
621 while let Some(rel) = source.get(from..)?.find(kind) {
622 let at = from + rel;
623 let before_ok = at == 0
624 || source
625 .get(..at)
626 .and_then(|prefix| prefix.chars().next_back())
627 .is_some_and(char::is_whitespace);
628 let after = at + kind.len();
629 let after_ok = source
630 .get(after..)
631 .and_then(|rest| rest.chars().next())
632 .is_none_or(|c| !c.is_alphanumeric() && c != '_');
633 if before_ok && after_ok {
634 let mut out = String::with_capacity(source.len() + alias.len() + 4);
635 out.push_str(source.get(..at)?);
636 out.push_str("def ");
637 out.push_str(alias);
638 out.push_str(source.get(after..)?);
639 return Some(out);
640 }
641 from = after;
642 }
643 None
644}
645
646fn probe_path(target: &Utf8Path) -> Utf8PathBuf {
648 let stem = target.file_stem().unwrap_or("target");
649 let file = format!("{stem}__lap_axioms.lean");
650 match target.parent() {
651 Some(parent) if !parent.as_str().is_empty() => parent.join(file),
652 _ => Utf8PathBuf::from(file),
653 }
654}
655
656fn parse_axioms(output: &str, name: &str) -> Option<Vec<String>> {
661 let none_marker = format!("'{name}' does not depend on any axioms");
662 if output.contains(&none_marker) {
663 return Some(Vec::new());
664 }
665 let some_marker = format!("'{name}' depends on axioms:");
666 let after = output.find(&some_marker)? + some_marker.len();
667 let rest = &output[after..];
668 let open = rest.find('[')?;
669 let close = rest[open..].find(']')? + open;
670 let inner = &rest[open + 1..close];
671 Some(
672 inner
673 .split(',')
674 .map(|token| token.trim().to_owned())
675 .filter(|token| !token.is_empty())
676 .collect(),
677 )
678}
679
680fn message_mentions_sorry(message: &str) -> bool {
682 message.to_ascii_lowercase().contains("sorry")
683}
684
685async fn check_reverse_dep(request: &AcceptRequest<'_>) -> GuardStatus {
691 let module = module_for_target(request.workspace_root, request.target);
692
693 match build_module(request.workspace_root, &module, request.timeout).await {
694 Ok(true) => {}
695 Ok(false) => {
696 return GuardStatus::Rejected {
697 reason: RejectReason::ReverseDepFailed {
698 module: module.clone(),
699 detail: "lake build reported failure".to_owned(),
700 },
701 };
702 }
703 Err(detail) => {
704 return GuardStatus::Rejected {
705 reason: RejectReason::ReverseDepFailed { module, detail },
706 };
707 }
708 }
709
710 for importer in find_importers(request.workspace_root, &module) {
711 match build_module(request.workspace_root, &importer, request.timeout).await {
712 Ok(true) => {}
713 Ok(false) => {
714 return GuardStatus::Rejected {
715 reason: RejectReason::ReverseDepFailed {
716 module: importer,
717 detail: "importer failed to build against the edited module".to_owned(),
718 },
719 };
720 }
721 Err(detail) => {
722 warn!(%importer, %detail, "skipping importer that could not be built");
725 }
726 }
727 }
728
729 GuardStatus::Passed
730}
731
732fn module_for_target(root: &Utf8Path, target: &Utf8Path) -> String {
740 for src_dir in lake_source_dirs(root) {
741 if let Ok(stripped) = target.strip_prefix(Utf8Path::new(&src_dir)) {
742 return module_name(stripped, Utf8Path::new("."));
743 }
744 }
745 module_name(target, Utf8Path::new("."))
746}
747
748fn lake_source_dirs(root: &Utf8Path) -> Vec<String> {
755 let Ok(text) = std::fs::read_to_string(root.join("lakefile.toml")) else {
756 return Vec::new();
757 };
758 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
759 return Vec::new();
760 };
761 let mut dirs = Vec::new();
762 for table_key in ["lean_lib", "lean_exe"] {
763 let Some(entries) = value.get(table_key).and_then(toml::Value::as_array) else {
764 continue;
765 };
766 for entry in entries {
767 if let Some(dir) = entry.get("srcDir").and_then(toml::Value::as_str) {
768 let dir = dir.trim_matches('/');
769 if !dir.is_empty() && dir != "." {
770 dirs.push(dir.to_owned());
771 }
772 }
773 }
774 }
775 dirs.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
776 dirs.dedup();
777 dirs
778}
779
780async fn build_module(
783 root: &Utf8Path,
784 module: &str,
785 timeout: Duration,
786) -> std::result::Result<bool, String> {
787 match run_capture("lake", &["build", module], root, timeout).await {
788 Ok(output) => {
789 if !output.success {
790 debug!(%module, detail = %first_lines(&output.combined, 8), "lake build failed");
791 }
792 Ok(output.success)
793 }
794 Err(err) => Err(format!("lake build did not run: {err}")),
795 }
796}
797
798fn find_importers(root: &Utf8Path, module: &str) -> Vec<String> {
800 let Ok(files) = discover_lean_files(root, true) else {
801 return Vec::new();
802 };
803 let exact = format!("import {module}");
804 let prefix = format!("import {module} ");
805 let mut importers = Vec::new();
806 for file in files {
807 let path = file.as_path();
808 if path.as_str().contains("__lap_") {
809 continue;
810 }
811 let Ok(source) = std::fs::read_to_string(path) else {
812 continue;
813 };
814 let imports_it = source.lines().any(|line| {
815 let trimmed = line.trim();
816 trimmed == exact || trimmed.starts_with(&prefix)
817 });
818 if imports_it {
819 let relative = path.strip_prefix(root).unwrap_or(path);
820 let importer = module_for_target(root, relative);
821 if importer != module {
822 importers.push(importer);
823 }
824 }
825 if importers.len() >= MAX_IMPORTERS {
826 break;
827 }
828 }
829 importers.sort();
830 importers.dedup();
831 importers
832}
833
834pub fn check_negative_control(control: Option<&NegativeControl>) -> GuardStatus {
845 match control {
846 None => GuardStatus::Skipped {
847 note: "TODO(loop-phase): negative control needs the claim manifest".to_owned(),
848 },
849 Some(_) => GuardStatus::Skipped {
850 note: "TODO(loop-phase): negation compile is not implemented yet".to_owned(),
851 },
852 }
853}
854
855struct CommandOutput {
861 success: bool,
863 combined: String,
865}
866
867async fn run_capture(
872 program: &str,
873 args: &[&str],
874 cwd: &Utf8Path,
875 timeout: Duration,
876) -> Result<CommandOutput> {
877 let mut command = Command::new(program);
878 command
879 .args(args)
880 .current_dir(cwd)
881 .kill_on_drop(true)
882 .stdout(Stdio::piped())
883 .stderr(Stdio::piped());
884
885 let child = command.spawn()?;
886 match time::timeout(timeout, child.wait_with_output()).await {
887 Ok(result) => {
888 let output = result?;
889 let combined = format!(
890 "{}\n{}",
891 String::from_utf8_lossy(&output.stderr),
892 String::from_utf8_lossy(&output.stdout)
893 );
894 Ok(CommandOutput {
895 success: output.status.success(),
896 combined,
897 })
898 }
899 Err(_) => Ok(CommandOutput {
900 success: false,
901 combined: format!("timed out after {}s", timeout.as_secs()),
902 }),
903 }
904}
905
906fn first_lines(text: &str, count: usize) -> String {
908 text.lines()
909 .filter(|line| !line.trim().is_empty())
910 .take(count)
911 .collect::<Vec<_>>()
912 .join(" | ")
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn signature_splits_on_top_level_assign() {
921 assert_eq!(
922 statement_signature("theorem foo : 2 + 2 = 5 := by rfl"),
923 "theorem foo : 2 + 2 = 5"
924 );
925 }
926
927 #[test]
928 fn signature_ignores_assign_inside_binders() {
929 assert_eq!(
931 statement_signature("def f (n : Nat := 0) : Nat := n"),
932 "def f (n : Nat := 0) : Nat"
933 );
934 }
935
936 #[test]
937 fn signature_handles_multiline_body() {
938 let source = "theorem bar : 1 + 1 = 2 := by\n sorry";
939 assert_eq!(statement_signature(source), "theorem bar : 1 + 1 = 2");
940 }
941
942 #[test]
943 fn unchanged_body_keeps_statement() {
944 let original = "theorem bar : 1 + 1 = 2 := by\n sorry\n";
945 let patched = "theorem bar : 1 + 1 = 2 := by\n rfl\n";
946 let check = compare_statement(original, patched, 2);
947 assert!(matches!(check, StatementCheck::Unchanged { .. }));
948 }
949
950 #[test]
951 fn weakened_statement_is_flagged() {
952 let original = "theorem foo : 2 + 2 = 5 := by rfl\n";
953 let patched = "theorem foo : 2 + 2 = 4 := by rfl\n";
954 let check = compare_statement(original, patched, 1);
955 assert!(matches!(check, StatementCheck::Changed { .. }));
956 if let StatementCheck::Changed { before, after, .. } = check {
957 assert_eq!(before, "theorem foo : 2 + 2 = 5");
958 assert_eq!(after, "theorem foo : 2 + 2 = 4");
959 }
960 }
961
962 #[test]
963 fn missing_declaration_is_unverifiable() {
964 let check = compare_statement("-- just a comment\n", "-- just a comment\n", 1);
965 assert!(matches!(check, StatementCheck::Unverifiable { .. }));
966 }
967
968 #[test]
969 fn parses_no_axioms() {
970 let out = "'foo' does not depend on any axioms";
971 assert_eq!(parse_axioms(out, "foo"), Some(Vec::new()));
972 }
973
974 #[test]
975 fn parses_whitelisted_axioms() {
976 let out = "'usesClassical' depends on axioms: [propext, Classical.choice, Quot.sound]";
977 assert_eq!(
978 parse_axioms(out, "usesClassical"),
979 Some(vec![
980 "propext".to_owned(),
981 "Classical.choice".to_owned(),
982 "Quot.sound".to_owned(),
983 ])
984 );
985 let axioms = parse_axioms(out, "usesClassical").unwrap_or_default();
986 assert!(axioms.iter().all(|a| AXIOM_WHITELIST.contains(&a.as_str())));
987 }
988
989 #[test]
990 fn parses_sorry_axiom_as_offending() {
991 let out = "'baz' depends on axioms: [sorryAx]";
992 let axioms = parse_axioms(out, "baz").unwrap_or_default();
993 assert_eq!(axioms, vec!["sorryAx".to_owned()]);
994 assert!(
995 axioms
996 .iter()
997 .any(|a| !AXIOM_WHITELIST.contains(&a.as_str()))
998 );
999 }
1000
1001 #[test]
1002 fn parse_axioms_returns_none_for_other_decl() {
1003 let out = "'other' does not depend on any axioms";
1004 assert_eq!(parse_axioms(out, "foo"), None);
1005 }
1006
1007 #[test]
1008 fn sorry_message_is_detected() {
1009 assert!(message_mentions_sorry("declaration uses `sorry`"));
1010 assert!(!message_mentions_sorry("unsolved goals"));
1011 }
1012
1013 #[test]
1014 fn negative_control_is_a_documented_stub() {
1015 let status = check_negative_control(None);
1016 assert!(matches!(status, GuardStatus::Skipped { .. }));
1017 if let GuardStatus::Skipped { note } = status {
1018 assert!(note.contains("TODO(loop-phase)"));
1019 }
1020 }
1021
1022 #[test]
1023 fn probe_path_sits_next_to_the_target() {
1024 assert_eq!(
1025 probe_path(Utf8Path::new("Demo.lean")).as_str(),
1026 "Demo__lap_axioms.lean"
1027 );
1028 assert_eq!(
1029 probe_path(Utf8Path::new("src/Demo.lean")).as_str(),
1030 "src/Demo__lap_axioms.lean"
1031 );
1032 }
1033
1034 #[test]
1035 fn slice_between_reads_only_the_sentinel_window() {
1036 let combined = "'foo' does not depend on any axioms\n\
1038 BEGIN\n'foo' depends on axioms: [evil]\nEND\ntrailing\n";
1039 let window = slice_between(combined, "BEGIN", "END").unwrap_or("");
1040 assert!(window.contains("[evil]"));
1041 assert!(!window.contains("does not depend"));
1042 assert_eq!(parse_axioms(window, "foo"), Some(vec!["evil".to_owned()]));
1043 }
1044
1045 #[test]
1046 fn slice_between_needs_both_sentinels_in_order() {
1047 assert_eq!(slice_between("BEGIN only", "BEGIN", "END"), None);
1048 assert_eq!(slice_between("END before BEGIN", "BEGIN", "END"), None);
1049 assert_eq!(slice_between("neither", "BEGIN", "END"), None);
1050 }
1051
1052 #[test]
1053 fn alias_rewrites_example_into_named_def() {
1054 assert_eq!(
1055 alias_anonymous_decl("example : 2 = 2 := by rfl", "example", "__p").unwrap_or_default(),
1056 "def __p : 2 = 2 := by rfl"
1057 );
1058 }
1059
1060 #[test]
1061 fn alias_keeps_binders_and_swaps_only_the_keyword() {
1062 assert_eq!(
1063 alias_anonymous_decl("example (n : Nat) : n = n := by\n rfl", "example", "__p")
1064 .unwrap_or_default(),
1065 "def __p (n : Nat) : n = n := by\n rfl"
1066 );
1067 }
1068
1069 #[test]
1070 fn alias_is_none_when_keyword_is_absent() {
1071 assert_eq!(
1072 alias_anonymous_decl("theorem t : True := trivial", "example", "__p"),
1073 None
1074 );
1075 }
1076
1077 type BoxResult = std::result::Result<(), Box<dyn std::error::Error>>;
1078
1079 #[test]
1080 fn module_for_target_falls_back_to_root_relative() -> BoxResult {
1081 let dir = tempfile::TempDir::new()?;
1083 let root = Utf8Path::from_path(dir.path()).ok_or("non-UTF-8 temp path")?;
1084 assert_eq!(
1085 module_for_target(root, Utf8Path::new("Demo/Basic.lean")),
1086 "Demo.Basic"
1087 );
1088 Ok(())
1089 }
1090
1091 #[test]
1092 fn lake_source_dirs_reads_srcdir_and_maps_module() -> BoxResult {
1093 let dir = tempfile::TempDir::new()?;
1094 let root = Utf8Path::from_path(dir.path()).ok_or("non-UTF-8 temp path")?;
1095 std::fs::write(
1096 root.join("lakefile.toml"),
1097 "name = \"demo\"\n\n[[lean_lib]]\nname = \"Demo\"\nsrcDir = \"src\"\n",
1098 )?;
1099 assert_eq!(lake_source_dirs(root), vec!["src".to_owned()]);
1100 assert_eq!(
1101 module_for_target(root, Utf8Path::new("src/Demo/Basic.lean")),
1102 "Demo.Basic"
1103 );
1104 Ok(())
1105 }
1106}