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}