md_formatter/
lib.rs

1#[cfg(feature = "cli")]
2pub mod cli;
3pub mod formatter;
4pub mod parser;
5
6// Only include NAPI bindings when the napi feature is enabled
7#[cfg(feature = "napi")]
8pub mod napi;
9
10pub use formatter::{Formatter, OrderedListMode, WrapMode};
11pub use parser::{extract_frontmatter, parse_markdown};
12
13#[cfg(test)]
14mod tests {
15    use crate::{extract_frontmatter, parse_markdown, Formatter, OrderedListMode, WrapMode};
16
17    fn format_markdown(input: &str) -> String {
18        let events = parse_markdown(input);
19        let mut formatter = Formatter::new(80);
20        formatter.format(events)
21    }
22
23    fn format_markdown_always(input: &str) -> String {
24        let events = parse_markdown(input);
25        let mut formatter = Formatter::with_wrap_mode(80, WrapMode::Always);
26        formatter.format(events)
27    }
28
29    fn format_markdown_one(input: &str) -> String {
30        let events = parse_markdown(input);
31        let mut formatter = Formatter::with_options(80, WrapMode::default(), OrderedListMode::One);
32        formatter.format(events)
33    }
34
35    /// Format markdown with frontmatter support
36    fn format_markdown_full(input: &str) -> String {
37        let (frontmatter, content) = extract_frontmatter(input);
38        let events = parse_markdown(content);
39        let mut formatter = Formatter::with_wrap_mode(80, WrapMode::Always);
40        let formatted = formatter.format(events);
41
42        if let Some(fm) = frontmatter {
43            fm + &formatted
44        } else {
45            formatted
46        }
47    }
48
49    // ==========================================================
50    // Unit Tests
51    // ==========================================================
52
53    #[test]
54    fn test_heading_normalization() {
55        let input = "# Heading 1\n## Heading 2";
56        let output = format_markdown(input);
57        let expected = "# Heading 1\n\n## Heading 2\n";
58        assert_eq!(output, expected);
59    }
60
61    #[test]
62    fn test_list_normalization() {
63        let input = "- Item 1\n- Item 2\n- Item 3";
64        let output = format_markdown(input);
65        let expected = "- Item 1\n- Item 2\n- Item 3\n";
66        assert_eq!(output, expected);
67    }
68
69    #[test]
70    fn test_ordered_list_ascending_mode() {
71        // Default mode: items are numbered 1, 2, 3, ...
72        let input = "1. First\n1. Second\n1. Third";
73        let output = format_markdown(input);
74        assert!(output.contains("1. First"));
75        assert!(output.contains("2. Second"));
76        assert!(output.contains("3. Third"));
77    }
78
79    #[test]
80    fn test_multiple_ordered_list_ascending_mode() {
81        // Default mode: items are numbered 1, 2, 3, ...
82        let input = "1. First\n1. Second\n1. Third\n\nHere is one more\n\n1. Another\n1. List";
83        let output = format_markdown(input);
84        assert!(output.contains("1. First"));
85        assert!(output.contains("2. Second"));
86        assert!(output.contains("3. Third"));
87        assert!(output.contains("1. Another"));
88        assert!(output.contains("2. List"));
89    }
90
91    #[test]
92    fn test_nested_lists_have_no_empty_lines() {
93        let input = "- First\n- Second\n  - Subitem one\n  - Subitem two\n- Third";
94        let output = format_markdown(input);
95        assert!(!output.contains("\n\n  - Subitem one"));
96    }
97
98    #[test]
99    fn test_ordered_list_one_mode() {
100        // One mode: all items use "1."
101        let input = "1. First\n2. Second\n3. Third";
102        let output = format_markdown_one(input);
103        assert!(output.contains("1. First"));
104        assert!(output.contains("1. Second"));
105        assert!(output.contains("1. Third"));
106    }
107
108    #[test]
109    fn test_emphasis() {
110        let input = "This is *italic* and **bold** text.";
111        let output = format_markdown(input);
112        let expected = "This is *italic* and **bold** text.\n";
113        assert_eq!(output, expected);
114    }
115
116    #[test]
117    fn test_code_block() {
118        let input = "```rust\nfn main() {\n    println!(\"Hello\");\n}\n```";
119        let output = format_markdown(input);
120        assert!(output.contains("```"));
121        assert!(output.contains("fn main()"));
122    }
123
124    #[test]
125    fn test_text_wrapping() {
126        let input = "This is a very long line that should probably be wrapped because it exceeds the line width limit that we have set for the formatter.";
127        let output = format_markdown_always(input);
128        // Check that it was wrapped (has multiple lines)
129        assert!(output.lines().count() > 1);
130    }
131
132    #[test]
133    fn test_idempotence() {
134        let input = "# Hello\n\nThis is a paragraph with *emphasis*.\n\n- Item 1\n- Item 2\n";
135        let first_pass = format_markdown(input);
136        let second_pass = format_markdown(&first_pass);
137        assert_eq!(first_pass, second_pass, "Formatter should be idempotent");
138    }
139
140    #[test]
141    fn test_inline_code() {
142        let input = "Use `let x = 5;` for variable declaration.";
143        let output = format_markdown(input);
144        assert!(output.contains("`let x = 5;`"));
145    }
146
147    #[test]
148    fn test_horizontal_rule() {
149        let input = "Before\n\n---\n\nAfter";
150        let output = format_markdown(input);
151        assert!(output.contains("---"));
152    }
153
154    #[test]
155    fn test_nested_lists() {
156        let input = "- Item 1\n- Item 2\n  - Nested 1\n  - Nested 2\n- Item 3";
157        let output = format_markdown(input);
158        assert!(output.contains("  - Nested"));
159    }
160
161    #[test]
162    fn test_paragraph_wrapping() {
163        let input = "This is a short intro paragraph.\n\nThis is another paragraph that is quite long and should be wrapped nicely across multiple lines if needed based on the formatter's width settings.";
164        let output = format_markdown(input);
165        // Should have two paragraphs separated by blank line
166        let parts: Vec<&str> = output.split("\n\n").collect();
167        assert!(parts.len() >= 2);
168    }
169
170    #[test]
171    fn test_blockquote_formatting() {
172        let input = "> This is a blockquote\n> with multiple lines";
173        let output = format_markdown(input);
174        // Should preserve blockquote markers
175        assert!(output.contains(">"));
176        // Should be idempotent
177        let output2 = format_markdown(&output);
178        assert_eq!(output, output2);
179    }
180
181    #[test]
182    fn test_frontmatter_preservation() {
183        let input = "---\ntitle: Test\nauthor: Me\n---\n\n# Heading\n\nContent.";
184        let (frontmatter, content) = extract_frontmatter(input);
185
186        // Should extract frontmatter
187        assert!(frontmatter.is_some());
188        assert!(frontmatter.unwrap().contains("title:"));
189
190        // Remaining content should not include frontmatter
191        assert!(!content.contains("title:"));
192        assert!(content.contains("# Heading"));
193    }
194
195    #[test]
196    fn test_strikethrough_preservation() {
197        let input = "This has ~~strikethrough~~ text.";
198        let output = format_markdown(input);
199        // Strikethrough should be preserved
200        assert!(output.contains("~~strikethrough~~"));
201    }
202
203    #[test]
204    fn test_gfm_autolinks() {
205        let input = "Visit <https://example.com> for more info.";
206        let output = format_markdown(input);
207        // Should contain link reference
208        assert!(output.contains("example.com"));
209    }
210
211    #[test]
212    fn test_hard_break_preservation() {
213        let input = "Line one  \nLine two";
214        let output = format_markdown(input);
215        // Hard break should be preserved (two spaces before newline)
216        assert!(output.contains("  \n"), "Hard break should be preserved");
217    }
218
219    #[test]
220    fn test_no_spurious_hard_breaks() {
221        // A long line that gets wrapped should NOT have hard breaks (when using always mode)
222        let input = "This is a very long line that needs to be wrapped because it exceeds eighty characters.";
223        let output = format_markdown_always(input);
224        // Should not contain hard breaks (two spaces before newline)
225        assert!(
226            !output.contains("  \n"),
227            "Wrapped lines should not have hard breaks"
228        );
229    }
230
231    // ==========================================================
232    // Fixture-Based Tests
233    // ==========================================================
234
235    const SIMPLE_GOOD: &str = include_str!("../tests/fixtures/simple-good.md");
236    const SIMPLE_BAD: &str = include_str!("../tests/fixtures/simple-bad.md");
237    const COMPLEX_GOOD: &str = include_str!("../tests/fixtures/complex-good.md");
238    const COMPLEX_BAD: &str = include_str!("../tests/fixtures/complex-bad.md");
239
240    #[test]
241    fn test_simple_good_is_idempotent() {
242        let formatted = format_markdown(SIMPLE_GOOD);
243        let reformatted = format_markdown(&formatted);
244        assert_eq!(
245            formatted, reformatted,
246            "simple-good.md should be idempotent"
247        );
248    }
249
250    #[test]
251    fn test_simple_bad_formats_correctly() {
252        let formatted = format_markdown(SIMPLE_BAD);
253
254        // Should be idempotent after formatting
255        let reformatted = format_markdown(&formatted);
256        assert_eq!(
257            formatted, reformatted,
258            "Formatted simple-bad.md should be idempotent"
259        );
260
261        // Check specific fixes were applied
262        assert!(
263            formatted.contains("# Simple Document\n"),
264            "Heading should have single space"
265        );
266        assert!(
267            formatted.contains("- First item\n"),
268            "List items should use dash with single space"
269        );
270        assert!(
271            formatted.contains("1. "),
272            "Ordered list should use 1. format"
273        );
274    }
275
276    #[test]
277    fn test_complex_good_is_idempotent() {
278        let formatted = format_markdown_full(COMPLEX_GOOD);
279        let reformatted = format_markdown_full(&formatted);
280        assert_eq!(
281            formatted, reformatted,
282            "complex-good.md should be idempotent"
283        );
284    }
285
286    #[test]
287    fn test_complex_bad_formats_correctly() {
288        let formatted = format_markdown_full(COMPLEX_BAD);
289
290        // Should be idempotent after formatting
291        let reformatted = format_markdown_full(&formatted);
292        assert_eq!(
293            formatted, reformatted,
294            "Formatted complex-bad.md should be idempotent"
295        );
296
297        // Check frontmatter is preserved
298        assert!(
299            formatted.starts_with("---\n"),
300            "Frontmatter should be preserved"
301        );
302        assert!(
303            formatted.contains("title:"),
304            "Frontmatter content should be preserved"
305        );
306
307        // Check code blocks are preserved
308        assert!(
309            formatted.contains("```python"),
310            "Python code block should be preserved"
311        );
312        assert!(
313            formatted.contains("def hello_world():"),
314            "Code content should be preserved"
315        );
316
317        // Check hard breaks are preserved
318        assert!(
319            formatted.contains("  \n"),
320            "Hard breaks should be preserved"
321        );
322    }
323
324    #[test]
325    fn test_complex_preserves_code_blocks() {
326        let formatted = format_markdown_full(COMPLEX_GOOD);
327
328        // Code blocks should be completely unchanged
329        assert!(formatted.contains("def hello_world():"));
330        assert!(formatted.contains("    \"\"\"A simple greeting function.\"\"\""));
331        assert!(formatted.contains("const greet = (name) => {"));
332    }
333
334    #[test]
335    fn test_no_hard_breaks_in_wrapped_output() {
336        // Format the bad version with always mode and check that wrapped lines don't have hard breaks
337        let formatted = format_markdown_always(SIMPLE_BAD);
338
339        // Count hard breaks (lines ending with two spaces)
340        let hard_break_count = formatted
341            .lines()
342            .filter(|line| line.ends_with("  "))
343            .count();
344
345        // simple-bad.md has no intentional hard breaks, so there should be none
346        assert_eq!(
347            hard_break_count, 0,
348            "Wrapped lines should not introduce hard breaks"
349        );
350    }
351}