1use pulldown_cmark::{BrokenLink, CowStr, Event, Options, Parser, Tag, TagEnd};
2use std::collections::{HashMap, HashSet};
3use std::ops::Range;
4
5pub struct MarkdownParser<'a> {
6 content: &'a str,
7 lines: Vec<&'a str>,
8 line_offsets: Vec<usize>,
11 code_block_lines: HashSet<usize>,
13 code_lines: HashSet<usize>,
15 code_ranges: Vec<Range<usize>>,
17 ref_def_lines: HashSet<usize>,
19 ref_defs: HashMap<String, usize>,
21}
22
23impl<'a> MarkdownParser<'a> {
24 pub fn new(content: &'a str) -> Self {
25 let lines: Vec<&'a str> = content.lines().collect();
26 let line_offsets = build_line_offsets(content);
27 let (code_block_lines, code_lines, code_ranges) = build_code_info(content, &line_offsets);
28 let (ref_def_lines, ref_defs) = build_ref_def_info(content, &line_offsets);
29 Self {
30 content,
31 lines,
32 line_offsets,
33 code_block_lines,
34 code_lines,
35 code_ranges,
36 ref_def_lines,
37 ref_defs,
38 }
39 }
40
41 pub fn content(&self) -> &'a str {
42 self.content
43 }
44
45 pub fn lines(&self) -> &[&'a str] {
46 &self.lines
47 }
48
49 pub fn line_count(&self) -> usize {
50 self.lines.len()
51 }
52
53 pub fn get_line(&self, line_num: usize) -> Option<&'a str> {
54 if line_num > 0 && line_num <= self.lines.len() {
55 Some(self.lines[line_num - 1])
56 } else {
57 None
58 }
59 }
60
61 pub fn parse(&self) -> impl Iterator<Item = Event<'a>> + 'a {
62 Parser::new_ext(self.content, mk_options())
63 }
64
65 pub fn parse_with_offsets(&self) -> impl Iterator<Item = (Event<'a>, Range<usize>)> {
66 Parser::new_ext(self.content, mk_options()).into_offset_iter()
67 }
68
69 pub fn parse_with_broken_links(&self) -> impl Iterator<Item = (Event<'a>, Range<usize>)> + 'a {
74 Parser::new_with_broken_link_callback(
75 self.content,
76 mk_options(),
77 Some(|_broken: BrokenLink| Some((CowStr::from(""), CowStr::from("")))),
78 )
79 .into_offset_iter()
80 }
81
82 pub fn offset_to_line(&self, offset: usize) -> usize {
83 self.offset_to_position(offset).0
84 }
85
86 pub fn offset_to_position(&self, offset: usize) -> (usize, usize) {
87 let i = self.line_offsets.partition_point(|&start| start <= offset);
90 if i == 0 {
91 return (1, 1);
92 }
93 let line_idx = i - 1; let column = offset - self.line_offsets[line_idx] + 1;
95 (line_idx + 1, column) }
97
98 pub fn get_code_line_numbers(&self) -> &HashSet<usize> {
101 &self.code_lines
102 }
103
104 pub fn get_code_block_line_numbers(&self) -> &HashSet<usize> {
107 &self.code_block_lines
108 }
109
110 pub fn get_code_ranges(&self) -> &[Range<usize>] {
113 &self.code_ranges
114 }
115
116 pub fn get_ref_def_line_numbers(&self) -> &HashSet<usize> {
119 &self.ref_def_lines
120 }
121
122 pub fn get_ref_defs(&self) -> &HashMap<String, usize> {
125 &self.ref_defs
126 }
127
128 pub fn line_offset_to_absolute(&self, line_num: usize, byte_offset_in_line: usize) -> usize {
131 if line_num == 0 || line_num > self.line_offsets.len() {
132 return self.content.len();
133 }
134 self.line_offsets[line_num - 1] + byte_offset_in_line
135 }
136
137 pub fn is_heading(&self, event: &Event) -> bool {
138 matches!(event, Event::Start(Tag::Heading { .. }))
139 }
140
141 pub fn is_code_block(&self, event: &Event) -> bool {
142 matches!(event, Event::Start(Tag::CodeBlock(_)))
143 }
144
145 pub fn is_list(&self, event: &Event) -> bool {
146 matches!(event, Event::Start(Tag::List(_)))
147 }
148}
149
150fn mk_options() -> Options {
151 let mut options = Options::empty();
152 options.insert(Options::ENABLE_TABLES);
153 options.insert(Options::ENABLE_FOOTNOTES);
154 options.insert(Options::ENABLE_STRIKETHROUGH);
155 options.insert(Options::ENABLE_TASKLISTS);
156 options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
157 options
158}
159
160fn build_line_offsets(content: &str) -> Vec<usize> {
164 let mut offsets = vec![0usize];
165 for (i, byte) in content.bytes().enumerate() {
166 if byte == b'\n' {
167 let next = i + 1;
168 if next < content.len() {
169 offsets.push(next);
170 }
171 }
172 }
173 offsets
174}
175
176fn line_from_offset(offset: usize, line_offsets: &[usize]) -> usize {
179 let i = line_offsets.partition_point(|&start| start <= offset);
180 i.max(1)
181}
182
183fn build_code_info(
186 content: &str,
187 line_offsets: &[usize],
188) -> (HashSet<usize>, HashSet<usize>, Vec<Range<usize>>) {
189 let mut code_block_lines: HashSet<usize> = HashSet::new();
190 let mut code_lines: HashSet<usize> = HashSet::new();
191 let mut code_ranges: Vec<Range<usize>> = Vec::new();
192
193 let mut in_code_block = false;
194 let mut code_block_start = 0usize;
195
196 for (event, range) in Parser::new_ext(content, mk_options()).into_offset_iter() {
197 match event {
198 Event::Start(Tag::CodeBlock(_)) => {
199 in_code_block = true;
200 code_block_start = range.start;
201 let start_line = line_from_offset(range.start, line_offsets);
202 let end_line = line_from_offset(range.end, line_offsets);
203 for line in start_line..=end_line {
204 code_block_lines.insert(line);
205 code_lines.insert(line);
206 }
207 }
208 Event::End(TagEnd::CodeBlock) => {
209 if in_code_block {
210 code_ranges.push(code_block_start..range.end);
211 in_code_block = false;
212 }
213 }
214 Event::Code(_) => {
215 code_ranges.push(range.clone());
217 let start_line = line_from_offset(range.start, line_offsets);
218 let end_line = line_from_offset(range.end, line_offsets);
219 for line in start_line..=end_line {
220 code_lines.insert(line);
221 }
222 }
223 _ => {
224 if in_code_block {
225 let start_line = line_from_offset(range.start, line_offsets);
226 let end_line = line_from_offset(range.end, line_offsets);
227 for line in start_line..=end_line {
228 code_block_lines.insert(line);
229 code_lines.insert(line);
230 }
231 }
232 }
233 }
234 }
235
236 (code_block_lines, code_lines, code_ranges)
237}
238
239fn build_ref_def_info(
244 content: &str,
245 line_offsets: &[usize],
246) -> (HashSet<usize>, HashMap<String, usize>) {
247 let parser = Parser::new_ext(content, mk_options());
248 let mut line_set = HashSet::new();
249 let mut label_map = HashMap::new();
250 for (label, link_def) in parser.reference_definitions().iter() {
251 let start = line_from_offset(link_def.span.start, line_offsets);
252 let end = line_from_offset(link_def.span.end.saturating_sub(1), line_offsets);
253 for line in start..=end {
254 line_set.insert(line);
255 }
256 label_map.insert(label.to_string(), start);
257 }
258 (line_set, label_map)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_basic_parsing() {
267 let content = "# Heading\n\nSome **bold** text.";
268 let parser = MarkdownParser::new(content);
269
270 assert_eq!(parser.content(), content);
271 assert_eq!(parser.line_count(), 3);
272 }
273
274 #[test]
275 fn test_get_line() {
276 let content = "Line 1\nLine 2\nLine 3";
277 let parser = MarkdownParser::new(content);
278
279 assert_eq!(parser.get_line(1), Some("Line 1"));
280 assert_eq!(parser.get_line(2), Some("Line 2"));
281 assert_eq!(parser.get_line(3), Some("Line 3"));
282 assert_eq!(parser.get_line(0), None);
283 assert_eq!(parser.get_line(4), None);
284 }
285
286 #[test]
287 fn test_offset_to_line() {
288 let content = "Line 1\nLine 2\nLine 3";
289 let parser = MarkdownParser::new(content);
290
291 assert_eq!(parser.offset_to_line(0), 1);
292 assert_eq!(parser.offset_to_line(3), 1);
293 assert_eq!(parser.offset_to_line(7), 2);
294 assert_eq!(parser.offset_to_line(14), 3);
295 }
296
297 #[test]
298 fn test_offset_to_position() {
299 let content = "Line 1\nLine 2\nLine 3";
300 let parser = MarkdownParser::new(content);
301
302 assert_eq!(parser.offset_to_position(0), (1, 1));
303 assert_eq!(parser.offset_to_position(3), (1, 4));
304 assert_eq!(parser.offset_to_position(7), (2, 1));
305 }
306
307 #[test]
308 fn test_parse_events() {
309 let content = "# Heading";
310 let parser = MarkdownParser::new(content);
311
312 let events: Vec<_> = parser.parse().collect();
313 assert!(!events.is_empty());
314 assert!(parser.is_heading(&events[0]));
315 }
316
317 #[test]
318 fn test_parse_with_offsets() {
319 let content = "# Heading\n\nParagraph";
320 let parser = MarkdownParser::new(content);
321
322 let events: Vec<_> = parser.parse_with_offsets().collect();
323 assert!(!events.is_empty());
324 }
325
326 #[test]
327 fn test_event_type_checks() {
328 let content = "# Heading\n\n```rust\ncode\n```\n\n- item";
329 let parser = MarkdownParser::new(content);
330
331 let events: Vec<_> = parser.parse().collect();
332
333 let has_heading = events.iter().any(|e| parser.is_heading(e));
334 let has_code = events.iter().any(|e| parser.is_code_block(e));
335 let has_list = events.iter().any(|e| parser.is_list(e));
336
337 assert!(has_heading);
338 assert!(has_code);
339 assert!(has_list);
340 }
341
342 #[test]
343 fn test_code_line_numbers_fenced() {
344 let content = "Normal text\n\n```sql\nSELECT * FROM table_name\nWHERE user_id = 123\n```\n\nMore text";
345 let parser = MarkdownParser::new(content);
346 let code_lines = parser.get_code_line_numbers();
347
348 assert!(
350 code_lines.contains(&3),
351 "Line 3 (opening ```) should be code"
352 );
353 assert!(
354 code_lines.contains(&4),
355 "Line 4 (code content) should be code"
356 );
357 assert!(
358 code_lines.contains(&5),
359 "Line 5 (code content) should be code"
360 );
361 assert!(
362 code_lines.contains(&6),
363 "Line 6 (closing ```) should be code"
364 );
365
366 assert!(!code_lines.contains(&1), "Line 1 should not be code");
368 assert!(!code_lines.contains(&2), "Line 2 should not be code");
369 assert!(!code_lines.contains(&8), "Line 8 should not be code");
370 }
371
372 #[test]
373 fn test_code_line_numbers_inline() {
374 let content = "This is `inline_code_with_underscores` in text";
375 let parser = MarkdownParser::new(content);
376 let code_lines = parser.get_code_line_numbers();
377
378 assert!(
380 code_lines.contains(&1),
381 "Line with inline code should be marked"
382 );
383 }
384
385 #[test]
386 fn test_code_line_numbers_mixed() {
387 let content =
388 "Normal text\n\nText with `inline_code` here\n\n```\nCode block\n```\n\nFinal text";
389 let parser = MarkdownParser::new(content);
390 let code_lines = parser.get_code_line_numbers();
391
392 assert!(
394 code_lines.contains(&3),
395 "Line with inline code should be marked"
396 );
397
398 assert!(code_lines.contains(&5), "Code block line should be marked");
400 assert!(code_lines.contains(&6), "Code block line should be marked");
401 assert!(code_lines.contains(&7), "Code block line should be marked");
402
403 assert!(
405 !code_lines.contains(&1),
406 "Normal text line should not be marked"
407 );
408 assert!(!code_lines.contains(&2), "Empty line should not be marked");
409 assert!(
410 !code_lines.contains(&9),
411 "Normal text line should not be marked"
412 );
413 }
414
415 #[test]
416 fn test_build_line_offsets() {
417 let offsets = build_line_offsets("abc\ndef\nghi");
419 assert_eq!(offsets, vec![0, 4, 8]);
420
421 let offsets = build_line_offsets("abc\r\ndef\r\nghi");
423 assert_eq!(offsets, vec![0, 5, 10]);
424
425 let offsets = build_line_offsets("abc");
427 assert_eq!(offsets, vec![0]);
428
429 let offsets = build_line_offsets("");
431 assert_eq!(offsets, vec![0]);
432
433 let offsets = build_line_offsets("abc\n");
435 assert_eq!(offsets, vec![0]);
436 }
437
438 #[test]
439 fn test_offset_to_position_crlf() {
440 let content = "abc\r\ndef";
442 let parser = MarkdownParser::new(content);
443 assert_eq!(parser.offset_to_position(0), (1, 1));
444 assert_eq!(parser.offset_to_position(2), (1, 3));
445 assert_eq!(parser.offset_to_position(5), (2, 1));
446 assert_eq!(parser.offset_to_position(7), (2, 3));
447 }
448
449 #[test]
450 fn test_ref_def_line_numbers() {
451 let content = "Text\n\n[foo]: https://example.com\n\nMore text";
452 let parser = MarkdownParser::new(content);
453 let ref_def_lines = parser.get_ref_def_line_numbers();
454
455 assert!(ref_def_lines.contains(&3), "ref def line should be marked");
456 assert!(!ref_def_lines.contains(&1), "prose should not be marked");
457 assert!(!ref_def_lines.contains(&5), "prose should not be marked");
458 }
459}