1use crate::{
14 Diagnostic, GoalState, LeanFile, Provenance, Result, TraceConfig, capture_provenance,
15 run_lean_file,
16};
17use camino::Utf8PathBuf;
18use serde::{Deserialize, Serialize};
19use std::time::Duration;
20
21const NAMED_DECL_KEYWORDS: &[&str] = &[
23 "theorem",
24 "lemma",
25 "def",
26 "abbrev",
27 "instance",
28 "example",
29 "structure",
30 "inductive",
31 "class",
32 "opaque",
33 "axiom",
34];
35
36const DECL_MODIFIERS: &[&str] = &[
38 "private",
39 "protected",
40 "noncomputable",
41 "partial",
42 "unsafe",
43 "scoped",
44 "local",
45 "nonrec",
46];
47
48const BOUNDARY_KEYWORDS: &[&str] = &[
50 "namespace",
51 "section",
52 "end",
53 "open",
54 "import",
55 "variable",
56 "universe",
57 "set_option",
58 "attribute",
59 "mutual",
60 "macro",
61 "macro_rules",
62 "syntax",
63 "notation",
64 "elab",
65 "deriving",
66];
67
68#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ContextRequest {
71 pub file: LeanFile,
73 pub line: u32,
75 pub before: usize,
77 pub after: usize,
79}
80
81impl ContextRequest {
82 #[must_use]
84 pub const fn new(file: LeanFile, line: u32) -> Self {
85 Self {
86 file,
87 line,
88 before: 8,
89 after: 8,
90 }
91 }
92}
93
94#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct ContextOptions {
97 pub run_trace: bool,
99 pub lake_root: Utf8PathBuf,
101 pub timeout: Duration,
103 pub include_warnings: bool,
105}
106
107impl ContextOptions {
108 #[must_use]
110 pub fn new(lake_root: Utf8PathBuf) -> Self {
111 Self {
112 run_trace: true,
113 lake_root,
114 timeout: Duration::from_secs(60),
115 include_warnings: true,
116 }
117 }
118}
119
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122pub struct Declaration {
123 pub kind: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub name: Option<String>,
128 pub start_line: u32,
130 pub end_line: u32,
132 pub source: String,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
138pub struct SourceLine {
139 pub number: u32,
141 pub text: String,
143 pub is_target: bool,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
149pub struct SourceWindow {
150 pub start_line: u32,
152 pub end_line: u32,
154 pub target_line: u32,
156 pub lines: Vec<SourceLine>,
158}
159
160#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
162pub struct ContextBundle {
163 pub file: LeanFile,
165 pub line: u32,
167 pub total_lines: usize,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub declaration: Option<Declaration>,
172 pub imports: Vec<String>,
174 pub surrounding: SourceWindow,
176 pub diagnostics: Vec<Diagnostic>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub goal_state: Option<GoalState>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub provenance: Option<Provenance>,
184 pub suggested_prompt: String,
186}
187
188impl ContextBundle {
189 #[must_use]
191 pub fn to_markdown(&self) -> String {
192 render_markdown(self)
193 }
194}
195
196pub fn parse_file_line_spec(spec: &str) -> Result<(Utf8PathBuf, u32)> {
201 let (path, line) = spec
202 .rsplit_once(':')
203 .ok_or_else(|| crate::Error::InvalidLineSpec {
204 spec: spec.to_owned(),
205 })?;
206 let line: u32 = line.parse().map_err(|_| crate::Error::InvalidLineSpec {
207 spec: spec.to_owned(),
208 })?;
209 if path.is_empty() || line == 0 {
210 return Err(crate::Error::InvalidLineSpec {
211 spec: spec.to_owned(),
212 });
213 }
214 Ok((Utf8PathBuf::from(path), line))
215}
216
217pub async fn gather_context(
219 request: &ContextRequest,
220 options: &ContextOptions,
221) -> Result<ContextBundle> {
222 let source = std::fs::read_to_string(request.file.as_path())?;
223
224 let (diagnostics, provenance) = if options.run_trace {
225 let provenance = capture_provenance(options.lake_root.as_path()).await;
226 let mut config = TraceConfig::new(options.lake_root.clone()).timeout(options.timeout);
227 config.include_warnings = options.include_warnings;
228 let trace = run_lean_file(&config, &provenance, request.file.clone()).await;
229 (trace.diagnostics, Some(provenance))
230 } else {
231 (Vec::new(), None)
232 };
233
234 Ok(build_context(
235 request,
236 &source,
237 &diagnostics,
238 provenance.as_ref(),
239 ))
240}
241
242#[must_use]
248pub fn build_context(
249 request: &ContextRequest,
250 source: &str,
251 diagnostics: &[Diagnostic],
252 provenance: Option<&Provenance>,
253) -> ContextBundle {
254 let lines: Vec<&str> = source.lines().collect();
255 let total_lines = lines.len();
256 let target_line = clamp_line(request.line, total_lines);
257 let target_idx = (target_line as usize).saturating_sub(1);
258
259 let imports = collect_imports(&lines);
260 let declaration = detect_declaration(&lines, target_idx);
261 let surrounding = build_window(&lines, target_idx, request.before, request.after);
262 let (selected, goal_state) = select_diagnostics(diagnostics, target_line);
263
264 let suggested_prompt = build_prompt(
265 request.file.as_path().as_str(),
266 target_line,
267 total_lines,
268 declaration.as_ref(),
269 &imports,
270 &selected,
271 goal_state.as_ref(),
272 );
273
274 ContextBundle {
275 file: request.file.clone(),
276 line: target_line,
277 total_lines,
278 declaration,
279 imports,
280 surrounding,
281 diagnostics: selected,
282 goal_state,
283 provenance: provenance.cloned(),
284 suggested_prompt,
285 }
286}
287
288fn clamp_line(line: u32, total: usize) -> u32 {
290 if total == 0 {
291 return 1;
292 }
293 let max = total as u32;
294 line.clamp(1, max)
295}
296
297pub fn collect_imports(lines: &[&str]) -> Vec<String> {
299 lines
300 .iter()
301 .filter(|line| is_col0(line) && first_token(line) == Some("import"))
302 .map(|line| line.trim_end().to_owned())
303 .collect()
304}
305
306fn is_col0(line: &str) -> bool {
308 !line.is_empty() && !line.starts_with(char::is_whitespace)
309}
310
311fn first_token(line: &str) -> Option<&str> {
313 line.split_whitespace().next()
314}
315
316fn decl_kind_of_line(line: &str) -> Option<&str> {
318 if !is_col0(line) {
319 return None;
320 }
321 let keyword = line
322 .split_whitespace()
323 .find(|token| !DECL_MODIFIERS.contains(token))?;
324 NAMED_DECL_KEYWORDS.contains(&keyword).then_some(keyword)
325}
326
327fn is_boundary(line: &str) -> bool {
329 if !is_col0(line) {
330 return false;
331 }
332 if line.starts_with("@[") {
333 return true;
334 }
335 let Some(token) = first_token(line) else {
336 return false;
337 };
338 if token.starts_with('#') {
339 return true;
340 }
341 BOUNDARY_KEYWORDS.contains(&token) || decl_kind_of_line(line).is_some()
342}
343
344pub fn detect_declaration(lines: &[&str], target_idx: usize) -> Option<Declaration> {
349 if lines.is_empty() {
350 return None;
351 }
352 let scan_from = target_idx.min(lines.len() - 1);
353 let mut keyword_idx = None;
354 for idx in (0..=scan_from).rev() {
355 if decl_kind_of_line(lines[idx]).is_some() {
356 keyword_idx = Some(idx);
357 break;
358 }
359 if is_boundary(lines[idx]) {
360 break;
363 }
364 }
365 let keyword_idx = keyword_idx?;
366 let kind = decl_kind_of_line(lines[keyword_idx])?.to_owned();
367
368 let mut start = keyword_idx;
370 while start > 0 {
371 let prev = lines[start - 1];
372 if is_col0(prev) && prev.starts_with("@[") {
373 start -= 1;
374 } else {
375 break;
376 }
377 }
378
379 let mut end = lines.len();
381 for (offset, line) in lines.iter().enumerate().skip(keyword_idx + 1) {
382 if is_boundary(line) {
383 end = offset;
384 break;
385 }
386 }
387
388 while end > keyword_idx + 1 && lines[end - 1].trim().is_empty() {
390 end -= 1;
391 }
392
393 let source = lines[start..end].join("\n");
394 Some(Declaration {
395 name: decl_name(lines[keyword_idx], &kind),
396 kind,
397 start_line: (start as u32) + 1,
398 end_line: end as u32,
399 source,
400 })
401}
402
403fn decl_name(line: &str, kind: &str) -> Option<String> {
405 if kind == "example" {
406 return None;
407 }
408 let mut tokens = line.split_whitespace().skip_while(|t| t != &kind);
409 let _keyword = tokens.next()?;
410 let candidate = tokens.next()?;
411 let cut = candidate
412 .find(['(', '{', '[', ':'])
413 .unwrap_or(candidate.len());
414 let name = &candidate[..cut];
415 let valid = !name.is_empty()
416 && name
417 .chars()
418 .next()
419 .is_some_and(|c| c.is_alphabetic() || c == '_');
420 valid.then(|| name.to_owned())
421}
422
423fn build_window(lines: &[&str], target_idx: usize, before: usize, after: usize) -> SourceWindow {
425 if lines.is_empty() {
426 return SourceWindow {
427 start_line: 1,
428 end_line: 0,
429 target_line: 1,
430 lines: Vec::new(),
431 };
432 }
433 let start = target_idx.saturating_sub(before);
434 let end = (target_idx + after + 1).min(lines.len());
435 let window = lines[start..end]
436 .iter()
437 .enumerate()
438 .map(|(offset, text)| {
439 let number = (start + offset + 1) as u32;
440 SourceLine {
441 number,
442 text: (*text).to_owned(),
443 is_target: start + offset == target_idx,
444 }
445 })
446 .collect();
447 SourceWindow {
448 start_line: (start as u32) + 1,
449 end_line: end as u32,
450 target_line: (target_idx as u32) + 1,
451 lines: window,
452 }
453}
454
455fn select_diagnostics(all: &[Diagnostic], line: u32) -> (Vec<Diagnostic>, Option<GoalState>) {
457 let on_line: Vec<Diagnostic> = all
458 .iter()
459 .filter(|d| d.line == Some(line))
460 .cloned()
461 .collect();
462 let chosen = if on_line.is_empty() {
463 all.to_vec()
464 } else {
465 on_line
466 };
467 let goal = chosen
468 .iter()
469 .find_map(|d| d.goal_state.clone())
470 .or_else(|| chosen.iter().find_map(|d| goal_from_message(&d.message)));
471 (chosen, goal)
472}
473
474fn goal_from_message(message: &str) -> Option<GoalState> {
480 let lines: Vec<&str> = message.lines().collect();
481 let goal_idx = lines
482 .iter()
483 .rposition(|line| line.trim_start().starts_with('⊢'))?;
484 let mut start = goal_idx;
485 while start > 0 && !lines[start - 1].trim().is_empty() {
486 start -= 1;
487 }
488 let block = lines[start..=goal_idx].join("\n");
489 let block = block.trim();
490 (!block.is_empty()).then(|| GoalState(block.to_owned()))
491}
492
493fn build_prompt(
495 file: &str,
496 line: u32,
497 total_lines: usize,
498 declaration: Option<&Declaration>,
499 imports: &[String],
500 diagnostics: &[Diagnostic],
501 goal_state: Option<&GoalState>,
502) -> String {
503 let mut out = String::new();
504 out.push_str("You are editing a Lean 4 proof.\n\n");
505 out.push_str(&format!("File: {file} (line {line} of {total_lines})\n"));
506
507 match declaration {
508 Some(decl) => {
509 let named = decl
510 .name
511 .as_deref()
512 .map_or_else(|| decl.kind.clone(), |name| format!("{} {name}", decl.kind));
513 out.push_str(&format!("Declaration: {named}\n"));
514 }
515 None => out.push_str("Declaration: not detected\n"),
516 }
517
518 out.push('\n');
519 if imports.is_empty() {
520 out.push_str("Imports in scope: none found in this file.\n");
521 } else {
522 out.push_str("Imports in scope:\n");
523 for import in imports {
524 out.push_str(import);
525 out.push('\n');
526 }
527 }
528
529 if let Some(decl) = declaration {
530 out.push_str("\nCurrent declaration:\n");
531 out.push_str(&decl.source);
532 out.push('\n');
533 }
534
535 if let Some(goal) = goal_state {
536 out.push_str("\nGoal state at the target line:\n");
537 out.push_str(&goal.0);
538 out.push('\n');
539 }
540
541 if diagnostics.is_empty() {
542 out.push_str("\nCompiler diagnostics: none captured.\n");
543 } else {
544 out.push_str("\nCompiler diagnostics:\n");
545 for diagnostic in diagnostics {
546 let first = diagnostic.message.lines().next().unwrap_or("").trim();
547 out.push_str(&format!("- {first}\n"));
548 }
549 }
550
551 out.push_str(
552 "\nTask: rewrite the declaration so the file compiles with no errors. \
553Return only the corrected Lean source for this declaration. Keep the existing \
554name and signature unless the error requires a change, and do not add imports \
555unless they are needed.\n",
556 );
557 out
558}
559
560fn render_markdown(bundle: &ContextBundle) -> String {
562 let mut out = String::new();
563 out.push_str(&format!("# Lean context: {}\n\n", bundle.file));
564 out.push_str(&format!(
565 "Target line **{}** of {} total.\n\n",
566 bundle.line, bundle.total_lines
567 ));
568
569 if let Some(decl) = &bundle.declaration {
570 let named = decl.name.as_deref().map_or_else(
571 || decl.kind.clone(),
572 |name| format!("{} `{name}`", decl.kind),
573 );
574 out.push_str(&format!(
575 "## Declaration\n\n{named} (lines {}-{})\n\n```lean\n{}\n```\n\n",
576 decl.start_line, decl.end_line, decl.source
577 ));
578 }
579
580 out.push_str("## Imports\n\n");
581 if bundle.imports.is_empty() {
582 out.push_str("None found in this file.\n\n");
583 } else {
584 out.push_str("```lean\n");
585 for import in &bundle.imports {
586 out.push_str(import);
587 out.push('\n');
588 }
589 out.push_str("```\n\n");
590 }
591
592 out.push_str("## Surrounding source\n\n```lean\n");
593 for line in &bundle.surrounding.lines {
594 let marker = if line.is_target { ">" } else { " " };
595 out.push_str(&format!("{marker} {:>4} | {}\n", line.number, line.text));
596 }
597 out.push_str("```\n\n");
598
599 if let Some(goal) = &bundle.goal_state {
600 out.push_str(&format!("## Goal state\n\n```\n{}\n```\n\n", goal.0));
601 }
602
603 out.push_str("## Diagnostics\n\n");
604 if bundle.diagnostics.is_empty() {
605 out.push_str("None captured.\n\n");
606 } else {
607 for diagnostic in &bundle.diagnostics {
608 let first = diagnostic.message.lines().next().unwrap_or("").trim();
609 out.push_str(&format!("- {first}\n"));
610 }
611 out.push('\n');
612 }
613
614 if let Some(provenance) = &bundle.provenance {
615 out.push_str("## Provenance\n\n");
616 out.push_str(&format!(
617 "- lean: {}\n- lake: {}\n- git: {}\n\n",
618 provenance.lean_version.as_deref().unwrap_or("unknown"),
619 provenance.lake_version.as_deref().unwrap_or("unknown"),
620 provenance.git_commit.as_deref().unwrap_or("none"),
621 ));
622 }
623
624 out.push_str("## Suggested prompt\n\n```\n");
625 out.push_str(&bundle.suggested_prompt);
626 out.push_str("```\n");
627 out
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::DiagnosticSeverity;
634
635 const SAMPLE: &str = "import Init\nimport Mathlib.Tactic\n\nnamespace Demo\n\n@[simp]\ntheorem foo (n : Nat) : n = n := by\n rfl\n\ndef bar : Nat := 0\n\nend Demo\n";
636
637 fn request(line: u32) -> ContextRequest {
638 let file = LeanFile(Utf8PathBuf::from("Demo.lean"));
639 ContextRequest::new(file, line)
640 }
641
642 #[test]
643 fn parses_file_line_spec() -> Result<()> {
644 let (path, line) = parse_file_line_spec("src/Demo.lean:42")?;
645 assert_eq!(path, Utf8PathBuf::from("src/Demo.lean"));
646 assert_eq!(line, 42);
647 Ok(())
648 }
649
650 #[test]
651 fn rejects_bad_line_spec() {
652 assert!(parse_file_line_spec("no-line-here").is_err());
653 assert!(parse_file_line_spec("file.lean:0").is_err());
654 assert!(parse_file_line_spec("file.lean:abc").is_err());
655 assert!(parse_file_line_spec(":12").is_err());
656 }
657
658 #[test]
659 fn collects_imports() {
660 let bundle = build_context(&request(7), SAMPLE, &[], None);
661 assert_eq!(bundle.imports, vec!["import Init", "import Mathlib.Tactic"]);
662 }
663
664 #[test]
665 fn detects_enclosing_theorem_with_attribute() -> Result<()> {
666 let bundle = build_context(&request(8), SAMPLE, &[], None);
667 let Some(decl) = bundle.declaration else {
668 return Err(crate::Error::Todo {
669 feature: "expected a declaration",
670 });
671 };
672 assert_eq!(decl.kind, "theorem");
673 assert_eq!(decl.name.as_deref(), Some("foo"));
674 assert_eq!(decl.start_line, 6);
676 assert!(decl.source.starts_with("@[simp]"));
677 assert!(decl.source.contains("rfl"));
678 assert!(!decl.source.contains("def bar"));
680 Ok(())
681 }
682
683 #[test]
684 fn detects_following_def() -> Result<()> {
685 let bundle = build_context(&request(10), SAMPLE, &[], None);
686 let Some(decl) = bundle.declaration else {
687 return Err(crate::Error::Todo {
688 feature: "expected a declaration",
689 });
690 };
691 assert_eq!(decl.kind, "def");
692 assert_eq!(decl.name.as_deref(), Some("bar"));
693 Ok(())
694 }
695
696 #[test]
697 fn window_marks_target_and_respects_bounds() {
698 let mut req = request(7);
699 req.before = 2;
700 req.after = 1;
701 let bundle = build_context(&req, SAMPLE, &[], None);
702 assert_eq!(bundle.surrounding.start_line, 5);
703 assert_eq!(bundle.surrounding.end_line, 8);
704 let target: Vec<_> = bundle
705 .surrounding
706 .lines
707 .iter()
708 .filter(|l| l.is_target)
709 .collect();
710 assert_eq!(target.len(), 1);
711 assert_eq!(target[0].number, 7);
712 }
713
714 #[test]
715 fn clamps_out_of_range_line() {
716 let bundle = build_context(&request(9999), SAMPLE, &[], None);
717 assert_eq!(bundle.total_lines, 12);
718 assert_eq!(bundle.line, 12);
719 }
720
721 #[test]
722 fn selects_diagnostics_on_target_line_and_extracts_goal() {
723 let diags = vec![
724 Diagnostic {
725 file: Some(Utf8PathBuf::from("Demo.lean")),
726 line: Some(8),
727 column: Some(2),
728 severity: DiagnosticSeverity::Error,
729 message: "error: unsolved goals\n⊢ n = n".to_owned(),
730 goal_state: Some(GoalState("⊢ n = n".to_owned())),
731 },
732 Diagnostic {
733 file: Some(Utf8PathBuf::from("Demo.lean")),
734 line: Some(99),
735 column: None,
736 severity: DiagnosticSeverity::Warning,
737 message: "warning: elsewhere".to_owned(),
738 goal_state: None,
739 },
740 ];
741 let bundle = build_context(&request(8), SAMPLE, &diags, None);
742 assert_eq!(bundle.diagnostics.len(), 1);
743 assert_eq!(bundle.diagnostics[0].line, Some(8));
744 assert_eq!(
745 bundle.goal_state.as_ref().map(|g| g.0.as_str()),
746 Some("⊢ n = n")
747 );
748 assert!(bundle.suggested_prompt.contains("Goal state"));
749 }
750
751 #[test]
752 fn recovers_goal_from_untagged_message() {
753 let diags = vec![Diagnostic {
754 file: Some(Utf8PathBuf::from("Demo.lean")),
755 line: Some(7),
756 column: Some(2),
757 severity: DiagnosticSeverity::Error,
758 message: "error: Tactic `rfl` failed: the sides differ\n n + m\n\nn m : Nat\n⊢ n + m = m + n".to_owned(),
759 goal_state: None,
760 }];
761 let bundle = build_context(&request(7), SAMPLE, &diags, None);
762 assert_eq!(
763 bundle.goal_state.as_ref().map(|g| g.0.as_str()),
764 Some("n m : Nat\n⊢ n + m = m + n")
765 );
766 }
767
768 #[test]
769 fn empty_file_is_handled() {
770 let bundle = build_context(&request(1), "", &[], None);
771 assert_eq!(bundle.total_lines, 0);
772 assert!(bundle.declaration.is_none());
773 assert!(bundle.surrounding.lines.is_empty());
774 }
775
776 #[test]
777 fn markdown_includes_key_sections() {
778 let bundle = build_context(&request(8), SAMPLE, &[], None);
779 let md = bundle.to_markdown();
780 assert!(md.contains("# Lean context"));
781 assert!(md.contains("## Declaration"));
782 assert!(md.contains("## Imports"));
783 assert!(md.contains("## Surrounding source"));
784 assert!(md.contains("## Suggested prompt"));
785 assert!(md.contains("> "));
786 }
787
788 #[test]
789 fn prompt_opens_with_editing_lead() {
790 let bundle = build_context(&request(8), SAMPLE, &[], None);
791 assert!(
792 bundle
793 .suggested_prompt
794 .starts_with("You are editing a Lean 4 proof.")
795 );
796 }
797}