1use pulldown_cmark::Options;
2
3pub fn rumdl_parser_options() -> Options {
14 let mut options = Options::empty();
15 options.insert(Options::ENABLE_TABLES);
16 options.insert(Options::ENABLE_FOOTNOTES);
17 options.insert(Options::ENABLE_STRIKETHROUGH);
18 options.insert(Options::ENABLE_TASKLISTS);
19 options.insert(Options::ENABLE_SMART_PUNCTUATION);
20 options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
21 options.insert(Options::ENABLE_MATH);
22 options.insert(Options::ENABLE_GFM);
23 options.insert(Options::ENABLE_DEFINITION_LIST);
24 options.insert(Options::ENABLE_SUPERSCRIPT);
25 options.insert(Options::ENABLE_SUBSCRIPT);
26 options.insert(Options::ENABLE_WIKILINKS);
27 options
28}
29
30pub fn rumdl_parser_options_for(content: &str) -> Options {
36 let mut options = rumdl_parser_options();
37 if has_setext_attribute_line(content) {
38 options.remove(Options::ENABLE_HEADING_ATTRIBUTES);
39 }
40 options
41}
42
43fn has_setext_attribute_line(content: &str) -> bool {
57 fn trimmed(line: &str) -> &str {
58 line.trim_start_matches([' ', '\t', '\x0b', '\x0c', '>'])
59 .trim_end_matches([' ', '\t', '\x0b', '\x0c'])
60 }
61 let mut seek = 0;
62 while let Some(relative) = content[seek..].find('{') {
63 let brace = seek + relative;
64 let start = content[..brace].rfind(['\n', '\r']).map_or(0, |i| i + 1);
65 let end = brace + content[brace..].find(['\n', '\r']).unwrap_or(content.len() - brace);
66 let text = trimmed(&content[start..end]);
67 let next = if content.as_bytes().get(end) == Some(&b'\r') && content.as_bytes().get(end + 1) == Some(&b'\n') {
68 end + 2
69 } else {
70 (end + 1).min(content.len())
71 };
72 seek = next;
73 if !text.starts_with('{') || !text.ends_with('}') || next == content.len() {
74 continue;
75 }
76 let stop = next + content[next..].find(['\n', '\r']).unwrap_or(content.len() - next);
77 let underline = trimmed(&content[next..stop]);
78 if !underline.is_empty() && (underline.bytes().all(|b| b == b'=') || underline.bytes().all(|b| b == b'-')) {
79 return true;
80 }
81 }
82 false
83}
84
85pub fn code_span_ranges_without_math(content: &str) -> Vec<(usize, usize)> {
94 if !content.contains('`') {
95 return Vec::new();
96 }
97 let mut options = rumdl_parser_options_for(content);
98 options.remove(Options::ENABLE_MATH);
99 pulldown_cmark::Parser::new_ext(content, options)
100 .into_offset_iter()
101 .filter_map(|(event, range)| {
102 matches!(event, pulldown_cmark::Event::Code(_)).then_some((range.start, range.end))
103 })
104 .collect()
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use pulldown_cmark::Parser;
111
112 fn legacy_has_setext_attribute_line(content: &str) -> bool {
113 if !content.contains('{') {
114 return false;
115 }
116 let lines = content
118 .split('\n')
119 .flat_map(|line| line.strip_suffix('\r').unwrap_or(line).split('\r'));
120 let mut previous_is_attribute_block = false;
121 for line in lines {
122 let text = line
123 .trim_start_matches([' ', '\t', '\x0b', '\x0c', '>'])
124 .trim_end_matches([' ', '\t', '\x0b', '\x0c']);
125 let is_underline = !text.is_empty() && (text.bytes().all(|b| b == b'=') || text.bytes().all(|b| b == b'-'));
126 if previous_is_attribute_block && is_underline {
127 return true;
128 }
129 previous_is_attribute_block = text.starts_with('{') && text.ends_with('}');
130 }
131 false
132 }
133
134 fn parse_fully(content: &str) -> usize {
136 Parser::new_ext(content, rumdl_parser_options_for(content))
137 .into_offset_iter()
138 .count()
139 }
140
141 #[test]
142 fn parser_survives_a_setext_heading_whose_last_line_is_an_attribute_block() {
143 for content in [
144 ">o\n> {}\n>=\n",
145 ">o\n> {}\n>=\n",
146 ">o\n> {}\n>-\n",
147 "- o\n {}\n =\n",
148 "1. o\n {}\n =\n",
149 ">>o\n>> {}\n>>=\n",
150 "> - o\n> {}\n> =\n",
151 ">o\n> \u{c}{}\n>=\n",
152 ">o\n> \u{b}{}\n>=\n",
153 ">o\r\n> {}\r\n>=\r\n",
154 ">o\r> {}\r>=\r",
155 ] {
156 assert!(parse_fully(content) > 0, "{content:?}");
157 }
158 }
159
160 #[test]
161 fn parser_survives_every_short_combination_of_container_prefixes_and_attribute_lines() {
162 let prefixes = ["", ">", "> ", "> ", "> ", "- ", " ", "\t"];
163 let bodies = ["o", "{}", "{#a}", "x {}", "=", "-", "---", "", "{ }"];
164 let lines: Vec<String> = prefixes
165 .iter()
166 .flat_map(|p| bodies.iter().map(move |b| format!("{p}{b}")))
167 .collect();
168 let mut parsed = 0usize;
169 for a in &lines {
170 for b in &lines {
171 parse_fully(&format!("{a}\n{b}\n"));
172 for c in &lines {
173 parse_fully(&format!("{a}\n{b}\n{c}\n"));
174 parsed += 1;
175 }
176 }
177 }
178 assert!(parsed > 100_000, "corpus too small: {parsed}");
179 }
180
181 #[test]
182 fn setext_attribute_line_detects_the_shapes_that_corrupt_the_parse() {
183 for content in [
184 ">o\n> {}\n>=\n",
185 "- o\n {}\n =\n",
186 ">o\n> \u{c}{}\n>=\n",
187 ">o\n> {}\u{b}\n>-\n",
188 ">o\r\n> {}\r\n>=\r\n",
189 ">o\r> {}\r>=\r",
190 "> > o\n> > {#a .b}\n> > ---\n",
191 ] {
192 assert!(has_setext_attribute_line(content), "{content:?}");
193 assert!(
194 !rumdl_parser_options_for(content).contains(Options::ENABLE_HEADING_ATTRIBUTES),
195 "{content:?}"
196 );
197 }
198 }
199
200 #[test]
201 fn setext_attribute_line_leaves_other_text_with_attributes_enabled() {
202 for content in [
203 "# Heading {#id}\n",
204 "Title {#id}\n=====\n",
205 ">o\n> x{}\n>=\n",
206 "{}\n\n---\n",
207 "{\"a\": 1}\n",
208 "plain text\n",
209 "{}\nnot an underline\n",
210 "{}\n",
211 ] {
212 assert!(!has_setext_attribute_line(content), "{content:?}");
213 assert!(
214 rumdl_parser_options_for(content).contains(Options::ENABLE_HEADING_ATTRIBUTES),
215 "{content:?}"
216 );
217 }
218 }
219
220 #[test]
223 fn production_code_derives_parser_options_from_the_text_it_parses() {
224 fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
225 for entry in std::fs::read_dir(dir).unwrap() {
226 let path = entry.unwrap().path();
227 if path.is_dir() {
228 rust_files(&path, out);
229 } else if path.extension().is_some_and(|ext| ext == "rs") {
230 out.push(path);
231 }
232 }
233 }
234
235 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
236 let mut files = Vec::new();
237 rust_files(&src, &mut files);
238 assert!(files.len() > 100, "source scan found only {} files", files.len());
239
240 let mut offenders = Vec::new();
241 for path in files {
242 let name = path.file_name().unwrap().to_string_lossy().into_owned();
243 if name == "parser_options.rs" || name == "tests.rs" || name.ends_with("_tests.rs") {
244 continue;
245 }
246 let source = std::fs::read_to_string(&path).unwrap();
247 let production = source.split("#[cfg(test)]").next().unwrap_or(&source);
248 for (number, line) in production.lines().enumerate() {
249 let code = line.split("//").next().unwrap_or(line);
250 if code.contains("rumdl_parser_options()") {
251 offenders.push(format!("{}:{}", path.display(), number + 1));
252 }
253 }
254 }
255 assert!(
256 offenders.is_empty(),
257 "use rumdl_parser_options_for(<text>) instead of rumdl_parser_options() at: {offenders:#?}"
258 );
259 }
260
261 #[test]
262 fn attribute_candidates_match_the_legacy_line_walk() {
263 let mut lines = Vec::new();
264 for prefix in ["", ">", "> ", "\t", " > \t", "\x0b", "\x0c", "\u{a0}"] {
265 for body in ["{}", "{#a}", "{ }", "a{}", "{}a", "{", "}", "=", "-", "---", "===", ""] {
266 lines.push(format!("{prefix}{body}"));
267 }
268 }
269 for a in &lines {
270 for b in &lines {
271 for ending in ["\n", "\r\n", "\r"] {
272 for source in [format!("{a}{ending}{b}"), format!("x\n{a}{ending}{b}\r\n")] {
273 assert_eq!(
274 has_setext_attribute_line(&source),
275 legacy_has_setext_attribute_line(&source),
276 "{source:?}"
277 );
278 }
279 }
280 }
281 }
282 }
283
284 proptest::proptest! {
285 #[test]
286 fn attribute_candidates_match_generated_line_walk(
287 pieces in proptest::collection::vec(
288 proptest::sample::select(vec!["{", "}", "\n", "\r", "\r\n", ">", " ", "\t", "\x0b", "\x0c", "é", "\u{a0}", "=", "-", "--", "===", "abc", "\"", "{}"]),
289 0..100,
290 )
291 ) {
292 let source = pieces.concat();
293 proptest::prop_assert_eq!(has_setext_attribute_line(&source), legacy_has_setext_attribute_line(&source), "{:?}", source);
294 }
295 }
296}