Skip to main content

unparser_shared/
refine.rs

1//! This module refines the *shape* of already-rendered markdown without
2//! deleting any of its visible text.
3//!
4//! It is not a general markdown formatter, and it is not `cleanup` (the
5//! noise-removal stage each `un*` renderer already ships). `cleanup` deletes —
6//! page-number lines, repeated headers/footers, mojibake. This module never
7//! deletes: it enforces two invariants across every pass it runs.
8//!
9//! - **Lossless** — every visible text token present before a pass is still
10//!   present after it (a superset relation: a pass may *add* text, such as a
11//!   recovered table separator row, but never remove any).
12//! - **Idempotent** — `refine(refine(x)) == refine(x)`.
13//!
14//! Because of those two invariants, this module composes with a renderer's
15//! existing `cleanup` stage in either order, and never changes output that
16//! has already shipped to a consumer.
17//!
18//! `0.1.0` implements all five passes the design calls for:
19//! **table shape normalization** (missing-separator recovery and
20//! ragged-row widening), **ordered-list renumbering**
21//! (the round trip's serializer configuration), **link/image path
22//! normalization** (backslash-to-forward-slash, duplicate-slash collapse —
23//! real URLs and protocol-relative references are left
24//! untouched), **frontmatter normalization** (canonical `key: value`
25//! spacing, and dropping an entry or list item that
26//! exactly repeats an earlier one; key order is left as authored, since
27//! nothing asked for it to be sorted), and **section anchors**
28//! (a GitHub-compatible slug assigned via CommonMark's own heading-
29//! attributes extension, `# text {#id}`; a heading that already carries an
30//! `id` is left alone).
31//! [`refine`] always runs the CommonMark round trip (parse with
32//! `pulldown-cmark`, re-serialize with `pulldown-cmark-to-cmark`) every pass
33//! runs inside, plus the frontmatter handling and parse-failure fallback
34//! that round trip needs — that round trip alone is not a no-op (see
35//! `roundtrip`'s ordered-list
36//! renumbering, which changes output on *any* ordered list, not only a
37//! malformed one).
38//!
39//! One thing this pass deliberately does **not** attempt: a link or image
40//! destination containing a raw space is not valid CommonMark syntax at all
41//! outside `<...>` — it never becomes a `Link`/`Image` event to normalize.
42//! Probing found all three `un*` renderers emit destinations unescaped, so
43//! this can happen on real input; the fix belongs at the emission site instead.
44
45mod anchor;
46mod frontmatter;
47mod link;
48mod options;
49mod roundtrip;
50mod table;
51
52pub use options::RefineOptions;
53
54/// Refines the shape of `markdown` without deleting any of its visible text.
55///
56/// Returns `markdown` unchanged if it fails to parse as CommonMark. In
57/// practice `pulldown-cmark` accepts effectively any input as literal text
58/// rather than erroring, so this is a defensive floor — the
59/// CommonMark-preserved invariant — not an expected path.
60pub fn refine(markdown: &str, options: &RefineOptions) -> String {
61    let _ = options;
62    let (frontmatter, content) = frontmatter::extract(markdown);
63    let recovered = table::recover_missing_separators(content);
64    let widened = table::widen_ragged_tables(&recovered);
65    let events = anchor::assign_section_anchors(link::normalize_link_destinations(
66        roundtrip::parse(&widened),
67    ));
68    let Some(refined_body) = roundtrip::serialize(events) else {
69        return markdown.to_string();
70    };
71    match frontmatter {
72        Some(fm) => format!(
73            "{}\n{}",
74            frontmatter::normalize(fm),
75            refined_body.trim_start()
76        ),
77        None => refined_body,
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn round_trips_plain_markdown() {
87        // `pulldown-cmark-to-cmark` does not reproduce trailing-newline
88        // whitespace byte-for-byte — that is expected: the lossless
89        // invariant this crate guarantees is over *visible text tokens*
90        // (see `roundtrip_properties.rs`), not raw bytes.
91        let options = RefineOptions::default();
92        assert_eq!(refine("hello world\n", &options), "hello world");
93    }
94
95    #[test]
96    fn preserves_frontmatter_verbatim() {
97        let options = RefineOptions::default();
98        let input = "---\ntitle: x\n---\n\nhello\n";
99        let output = refine(input, &options);
100        assert!(
101            output.starts_with("---\ntitle: x\n---\n"),
102            "frontmatter not preserved verbatim: {output:?}"
103        );
104    }
105
106    #[test]
107    fn drops_an_exact_duplicate_frontmatter_entry() {
108        let options = RefineOptions::default();
109        let input = "---\ntitle: \"x\"\ntitle: \"x\"\n---\n\nhello\n";
110        let output = refine(input, &options);
111        assert_eq!(
112            output.matches("title:").count(),
113            1,
114            "exact duplicate entry not deduped: {output:?}"
115        );
116    }
117
118    #[test]
119    fn keeps_a_same_key_entry_with_a_different_value() {
120        let options = RefineOptions::default();
121        let input = "---\ntitle: \"x\"\ntitle: \"y\"\n---\n\nhello\n";
122        let output = refine(input, &options);
123        assert!(
124            output.contains("\"x\"") && output.contains("\"y\""),
125            "a non-duplicate value was dropped: {output:?}"
126        );
127    }
128
129    #[test]
130    fn assigns_a_section_anchor_to_a_heading() {
131        let options = RefineOptions::default();
132        let output = refine("# Getting Started\n", &options);
133        assert!(
134            output.contains("{ #getting-started }"),
135            "no anchor assigned: {output:?}"
136        );
137    }
138
139    #[test]
140    fn round_trips_a_table() {
141        let options = RefineOptions::default();
142        let input = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
143        let output = refine(input, &options);
144        for token in ["a", "b", "1", "2"] {
145            assert!(output.contains(token), "missing {token:?} in {output:?}");
146        }
147    }
148
149    #[test]
150    fn recovers_a_table_missing_its_separator_row() {
151        // Integration-level check that the text pre-pass (`table.rs`) and the
152        // CommonMark round trip compose: without recovery this input parses
153        // as a plain paragraph containing literal `|` characters, not a
154        // table, so `pulldown-cmark-to-cmark` would re-escape those pipes
155        // (`\|`) rather than render them as table syntax.
156        let options = RefineOptions::default();
157        let input = "| a | b |\n| 1 | 2 |\n";
158        let output = refine(input, &options);
159        assert!(
160            !output.contains(r"\|"),
161            "pipes were escaped, meaning the block was not recognized as a table: {output:?}"
162        );
163        for token in ["a", "b", "1", "2"] {
164            assert!(output.contains(token), "missing {token:?} in {output:?}");
165        }
166    }
167
168    #[test]
169    fn preserves_a_row_wider_than_the_header_by_widening_it() {
170        // Regression: `pulldown-cmark` drops any cell beyond the header's
171        // column count while *parsing*, even for an already-well-formed
172        // table — a plain round trip with no passes at all already lost
173        // "hello" here (found empirically while building this pass).
174        // `refine` must widen the header, not just leave the loss
175        // unaddressed.
176        let options = RefineOptions::default();
177        let input = "| a | b |\n| --- | --- |\n| 1 | 2 | hello |\n";
178        let output = refine(input, &options);
179        assert!(output.contains("hello"), "excess cell was lost: {output:?}");
180    }
181
182    #[test]
183    fn pads_a_short_data_row_with_an_empty_cell() {
184        // pulldown-cmark itself pads a short row during parsing (probed
185        // empirically) — this pins that the round trip materializes the
186        // pad as real table syntax rather than dropping it.
187        let options = RefineOptions::default();
188        let input = "| a | b | c |\n| --- | --- | --- |\n| 1 | 2 |\n";
189        let output = refine(input, &options);
190        let data_row = output
191            .lines()
192            .find(|line| line.contains('1'))
193            .expect("data row present");
194        assert_eq!(
195            data_row.matches('|').count(),
196            4,
197            "expected a padded 3-cell row (4 pipes): {data_row:?}"
198        );
199    }
200}