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_ordered_list_one_mode() {
93        // One mode: all items use "1."
94        let input = "1. First\n2. Second\n3. Third";
95        let output = format_markdown_one(input);
96        assert!(output.contains("1. First"));
97        assert!(output.contains("1. Second"));
98        assert!(output.contains("1. Third"));
99    }
100
101    #[test]
102    fn test_emphasis() {
103        let input = "This is *italic* and **bold** text.";
104        let output = format_markdown(input);
105        let expected = "This is *italic* and **bold** text.\n";
106        assert_eq!(output, expected);
107    }
108
109    #[test]
110    fn test_code_block() {
111        let input = "```rust\nfn main() {\n    println!(\"Hello\");\n}\n```";
112        let output = format_markdown(input);
113        assert!(output.contains("```"));
114        assert!(output.contains("fn main()"));
115    }
116
117    #[test]
118    fn test_text_wrapping() {
119        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.";
120        let output = format_markdown_always(input);
121        // Check that it was wrapped (has multiple lines)
122        assert!(output.lines().count() > 1);
123    }
124
125    #[test]
126    fn test_idempotence() {
127        let input = "# Hello\n\nThis is a paragraph with *emphasis*.\n\n- Item 1\n- Item 2\n";
128        let first_pass = format_markdown(input);
129        let second_pass = format_markdown(&first_pass);
130        assert_eq!(first_pass, second_pass, "Formatter should be idempotent");
131    }
132
133    #[test]
134    fn test_inline_code() {
135        let input = "Use `let x = 5;` for variable declaration.";
136        let output = format_markdown(input);
137        assert!(output.contains("`let x = 5;`"));
138    }
139
140    #[test]
141    fn test_horizontal_rule() {
142        let input = "Before\n\n---\n\nAfter";
143        let output = format_markdown(input);
144        assert!(output.contains("---"));
145    }
146
147    #[test]
148    fn test_nested_lists() {
149        let input = "- Item 1\n- Item 2\n  - Nested 1\n  - Nested 2\n- Item 3";
150        let output = format_markdown(input);
151        assert!(output.contains("  - Nested"));
152    }
153
154    #[test]
155    fn test_paragraph_wrapping() {
156        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.";
157        let output = format_markdown(input);
158        // Should have two paragraphs separated by blank line
159        let parts: Vec<&str> = output.split("\n\n").collect();
160        assert!(parts.len() >= 2);
161    }
162
163    #[test]
164    fn test_blockquote_formatting() {
165        let input = "> This is a blockquote\n> with multiple lines";
166        let output = format_markdown(input);
167        // Should preserve blockquote markers
168        assert!(output.contains(">"));
169        // Should be idempotent
170        let output2 = format_markdown(&output);
171        assert_eq!(output, output2);
172    }
173
174    #[test]
175    fn test_frontmatter_preservation() {
176        let input = "---\ntitle: Test\nauthor: Me\n---\n\n# Heading\n\nContent.";
177        let (frontmatter, content) = extract_frontmatter(input);
178
179        // Should extract frontmatter
180        assert!(frontmatter.is_some());
181        assert!(frontmatter.unwrap().contains("title:"));
182
183        // Remaining content should not include frontmatter
184        assert!(!content.contains("title:"));
185        assert!(content.contains("# Heading"));
186    }
187
188    #[test]
189    fn test_strikethrough_preservation() {
190        let input = "This has ~~strikethrough~~ text.";
191        let output = format_markdown(input);
192        // Strikethrough should be preserved
193        assert!(output.contains("~~strikethrough~~"));
194    }
195
196    #[test]
197    fn test_gfm_autolinks() {
198        let input = "Visit <https://example.com> for more info.";
199        let output = format_markdown(input);
200        // Should contain link reference
201        assert!(output.contains("example.com"));
202    }
203
204    #[test]
205    fn test_hard_break_preservation() {
206        let input = "Line one  \nLine two";
207        let output = format_markdown(input);
208        // Hard break should be preserved (two spaces before newline)
209        assert!(output.contains("  \n"), "Hard break should be preserved");
210    }
211
212    #[test]
213    fn test_no_spurious_hard_breaks() {
214        // A long line that gets wrapped should NOT have hard breaks (when using always mode)
215        let input = "This is a very long line that needs to be wrapped because it exceeds eighty characters.";
216        let output = format_markdown_always(input);
217        // Should not contain hard breaks (two spaces before newline)
218        assert!(
219            !output.contains("  \n"),
220            "Wrapped lines should not have hard breaks"
221        );
222    }
223
224    // ==========================================================
225    // Fixture-Based Tests
226    // ==========================================================
227
228    const SIMPLE_GOOD: &str = include_str!("../tests/fixtures/simple-good.md");
229    const SIMPLE_BAD: &str = include_str!("../tests/fixtures/simple-bad.md");
230    const COMPLEX_GOOD: &str = include_str!("../tests/fixtures/complex-good.md");
231    const COMPLEX_BAD: &str = include_str!("../tests/fixtures/complex-bad.md");
232
233    #[test]
234    fn test_simple_good_is_idempotent() {
235        let formatted = format_markdown(SIMPLE_GOOD);
236        let reformatted = format_markdown(&formatted);
237        assert_eq!(
238            formatted, reformatted,
239            "simple-good.md should be idempotent"
240        );
241    }
242
243    #[test]
244    fn test_simple_bad_formats_correctly() {
245        let formatted = format_markdown(SIMPLE_BAD);
246
247        // Should be idempotent after formatting
248        let reformatted = format_markdown(&formatted);
249        assert_eq!(
250            formatted, reformatted,
251            "Formatted simple-bad.md should be idempotent"
252        );
253
254        // Check specific fixes were applied
255        assert!(
256            formatted.contains("# Simple Document\n"),
257            "Heading should have single space"
258        );
259        assert!(
260            formatted.contains("- First item\n"),
261            "List items should use dash with single space"
262        );
263        assert!(
264            formatted.contains("1. "),
265            "Ordered list should use 1. format"
266        );
267    }
268
269    #[test]
270    fn test_complex_good_is_idempotent() {
271        let formatted = format_markdown_full(COMPLEX_GOOD);
272        let reformatted = format_markdown_full(&formatted);
273        assert_eq!(
274            formatted, reformatted,
275            "complex-good.md should be idempotent"
276        );
277    }
278
279    #[test]
280    fn test_complex_bad_formats_correctly() {
281        let formatted = format_markdown_full(COMPLEX_BAD);
282
283        // Should be idempotent after formatting
284        let reformatted = format_markdown_full(&formatted);
285        assert_eq!(
286            formatted, reformatted,
287            "Formatted complex-bad.md should be idempotent"
288        );
289
290        // Check frontmatter is preserved
291        assert!(
292            formatted.starts_with("---\n"),
293            "Frontmatter should be preserved"
294        );
295        assert!(
296            formatted.contains("title:"),
297            "Frontmatter content should be preserved"
298        );
299
300        // Check code blocks are preserved
301        assert!(
302            formatted.contains("```python"),
303            "Python code block should be preserved"
304        );
305        assert!(
306            formatted.contains("def hello_world():"),
307            "Code content should be preserved"
308        );
309
310        // Check hard breaks are preserved
311        assert!(
312            formatted.contains("  \n"),
313            "Hard breaks should be preserved"
314        );
315    }
316
317    #[test]
318    fn test_complex_preserves_code_blocks() {
319        let formatted = format_markdown_full(COMPLEX_GOOD);
320
321        // Code blocks should be completely unchanged
322        assert!(formatted.contains("def hello_world():"));
323        assert!(formatted.contains("    \"\"\"A simple greeting function.\"\"\""));
324        assert!(formatted.contains("const greet = (name) => {"));
325    }
326
327    #[test]
328    fn test_no_hard_breaks_in_wrapped_output() {
329        // Format the bad version with always mode and check that wrapped lines don't have hard breaks
330        let formatted = format_markdown_always(SIMPLE_BAD);
331
332        // Count hard breaks (lines ending with two spaces)
333        let hard_break_count = formatted
334            .lines()
335            .filter(|line| line.ends_with("  "))
336            .count();
337
338        // simple-bad.md has no intentional hard breaks, so there should be none
339        assert_eq!(
340            hard_break_count, 0,
341            "Wrapped lines should not introduce hard breaks"
342        );
343    }
344}