1use lemma::{format_source, Error, SourceType};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct LemmaFence {
8 pub line: usize,
10 pub end_line: usize,
12 pub content: String,
14}
15
16#[derive(Debug)]
18pub struct FenceFormatError {
19 pub fence_line: usize,
21 pub fence_source: String,
23 pub error: Error,
24}
25
26pub fn extract_lemma_fences(content: &str) -> Vec<LemmaFence> {
31 let mut blocks = Vec::new();
32 let mut in_fence = false;
33 let mut fence_line = 0usize;
34 let mut current = String::new();
35 for (line_index, line) in content.lines().enumerate() {
36 let line_number = line_index + 1;
37 let trimmed = line.trim();
38 if trimmed == "```lemma" {
39 in_fence = true;
40 fence_line = line_number;
41 current.clear();
42 continue;
43 }
44 if in_fence && trimmed == "```" {
45 in_fence = false;
46 let block = current.trim().to_string();
47 if !block.is_empty() {
48 blocks.push(LemmaFence {
49 line: fence_line,
50 end_line: line_number,
51 content: block,
52 });
53 }
54 continue;
55 }
56 if in_fence {
57 current.push_str(line);
58 current.push('\n');
59 }
60 }
61 blocks
62}
63
64pub fn group_whitespace_adjacent_fences(file: &str, fences: Vec<LemmaFence>) -> Vec<LemmaFence> {
66 let lines: Vec<&str> = file.lines().collect();
67 let mut groups: Vec<LemmaFence> = Vec::new();
68 for fence in fences {
69 if let Some(prev) = groups.last_mut() {
70 let between_start = prev.end_line;
71 let between_end = fence.line.saturating_sub(1);
72 let only_whitespace = (between_start..between_end).all(|idx| {
73 lines
74 .get(idx)
75 .is_none_or(|line| line.chars().all(char::is_whitespace))
76 });
77 if only_whitespace {
78 prev.content.push_str("\n\n");
79 prev.content.push_str(&fence.content);
80 prev.end_line = fence.end_line;
81 continue;
82 }
83 }
84 groups.push(fence);
85 }
86 groups
87}
88
89pub fn format_lemma_fences_in_text(content: &str) -> Result<String, FenceFormatError> {
97 let lines: Vec<&str> = content.lines().collect();
98 let mut out = String::new();
99 let mut i = 0usize;
100 while i < lines.len() {
101 let trimmed = lines[i].trim();
102 if trimmed == "```lemma" {
103 out.push_str(lines[i]);
104 out.push('\n');
105 let fence_line = i + 1;
106 i += 1;
107 let mut body = String::new();
108 while i < lines.len() && lines[i].trim() != "```" {
109 body.push_str(lines[i]);
110 body.push('\n');
111 i += 1;
112 }
113 let trimmed_body = body.trim();
114 if trimmed_body.is_empty() {
115 out.push_str(&body);
116 } else {
117 let formatted =
118 format_source(trimmed_body, SourceType::Volatile).map_err(|error| {
119 FenceFormatError {
120 fence_line,
121 fence_source: trimmed_body.to_string(),
122 error,
123 }
124 })?;
125 out.push_str(&formatted);
126 }
127 if i < lines.len() {
128 out.push_str(lines[i]);
129 out.push('\n');
130 i += 1;
131 }
132 continue;
133 }
134 out.push_str(lines[i]);
135 out.push('\n');
136 i += 1;
137 }
138 if content.is_empty() {
139 return Ok(String::new());
140 }
141 if !content.ends_with('\n') && out.ends_with('\n') {
142 out.pop();
143 }
144 Ok(out)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn extract_skips_lemma_skip_and_empty() {
153 let file = "\
154```lemma-skip
155spec bad
156```
157
158```lemma
159```
160
161```lemma
162spec ok
163data x: 1
164```
165";
166 let fences = extract_lemma_fences(file);
167 assert_eq!(fences.len(), 1);
168 assert_eq!(fences[0].line, 8);
169 assert!(fences[0].content.contains("spec ok"));
170 }
171
172 #[test]
173 fn group_merges_whitespace_adjacent() {
174 let file = "\
175```lemma
176spec a
177data x: 1
178```
179
180```lemma
181spec b
182uses a
183rule r: a.x
184```
185
186prose
187
188```lemma
189spec c
190data y: 2
191```
192";
193 let groups = group_whitespace_adjacent_fences(file, extract_lemma_fences(file));
194 assert_eq!(groups.len(), 2);
195 assert!(groups[0].content.contains("spec a") && groups[0].content.contains("spec b"));
196 assert!(groups[1].content.contains("spec c"));
197 }
198
199 #[test]
200 fn format_rewrites_fence_body_preserves_prose() {
201 let file = "\
202# Title
203
204```lemma
205spec demo
206data X: 1
207rule Y: X
208```
209
210done
211";
212 let formatted = format_lemma_fences_in_text(file).expect("format");
213 assert!(formatted.contains("# Title"));
214 assert!(formatted.contains("done"));
215 assert!(formatted.contains("```lemma\n"));
216 assert!(formatted.contains("data x: 1"));
217 assert!(formatted.contains("rule y:\n"));
218 let again = format_lemma_fences_in_text(&formatted).expect("idempotent");
219 assert_eq!(formatted, again);
220 }
221
222 #[test]
223 fn format_leaves_lemma_skip_alone() {
224 let file = "\
225```lemma-skip
226spec Weird
227 data X: 1
228```
229";
230 let formatted = format_lemma_fences_in_text(file).expect("format");
231 assert_eq!(file, formatted);
232 }
233
234 #[test]
235 fn format_reports_parse_error_with_fence_line() {
236 let file = "\
237prose
238
239```lemma
240this is not lemma
241```
242";
243 let err = format_lemma_fences_in_text(file).expect_err("must fail");
244 assert_eq!(err.fence_line, 3);
245 }
246}