Skip to main content

lemma_cli/
doc_fences.rs

1//! Extract and format `` ```lemma `` fences inside markdown / text documents.
2
3use lemma::{format_source, Error, SourceType};
4
5/// One visual `` ```lemma `` fence (not adjacent-grouped).
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct LemmaFence {
8    /// 1-based line of the opening `` ```lemma `` marker.
9    pub line: usize,
10    /// Inclusive 1-based line of the closing `` ``` ``.
11    pub end_line: usize,
12    /// Fence body with outer whitespace trimmed.
13    pub content: String,
14}
15
16/// Parse failure while formatting a fence body.
17#[derive(Debug)]
18pub struct FenceFormatError {
19    /// 1-based line of the opening fence marker.
20    pub fence_line: usize,
21    /// Body that failed to format (trimmed).
22    pub fence_source: String,
23    pub error: Error,
24}
25
26/// Extract every non-empty `` ```lemma `` fence (`` ```lemma-skip `` ignored).
27///
28/// Does not merge whitespace-adjacent fences; callers that load multi-spec
29/// examples as one workspace should call [`group_whitespace_adjacent_fences`].
30pub 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
64/// Merge fences that have only blank/whitespace lines between them into one load unit.
65pub 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
89/// Rewrite every `` ```lemma `` fence body to canonical `format_source` output.
90///
91/// Prose, fence markers, and `` ```lemma-skip `` blocks are unchanged. Each visual
92/// fence is formatted on its own (adjacent fences are not merged).
93///
94/// The returned document always ends with a trailing newline when it contains
95/// at least one line (same as walking `lines()` and re-joining).
96pub 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}