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