1use super::djot::parsing::parse_frontmatter;
9use super::{CitationParser, CitationPlacement, CitationStructure, ParsedCitation, ParsedDocument};
10use crate::processor::document::ManualNoteReference;
11use crate::{Citation, CitationItem};
12use citum_schema::citation::{CitationMode, normalize_locator_text};
13use citum_schema::locale::Locale;
14use std::collections::HashSet;
15use std::ops::Range;
16
17struct FootnoteRange {
23 label: String,
24 content: Range<usize>,
25}
26
27pub struct MarkdownParser;
29
30impl Default for MarkdownParser {
31 fn default() -> Self {
32 Self
33 }
34}
35
36impl CitationParser for MarkdownParser {
37 fn finalize_html_output(&self, rendered: &str) -> String {
45 use pulldown_cmark::{Options, html};
46
47 let (remapped, token_map) = remap_nul_tokens(rendered);
48 let parser = pulldown_cmark::Parser::new_ext(
49 &remapped,
50 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_FOOTNOTES | Options::ENABLE_TABLES,
51 );
52 let mut out = String::new();
53 html::push_html(&mut out, parser);
54
55 for (comment, original) in token_map {
57 out = out.replace(&comment, &original);
58 }
59 out
60 }
61
62 fn render_body_markup<F>(&self, body: &str, fmt: &F) -> String
65 where
66 F: crate::render::format::OutputFormat<Output = String>,
67 {
68 crate::render::markup::render_markdown_body(body, fmt)
69 }
70
71 fn parse_document(&self, content: &str, locale: &Locale) -> ParsedDocument {
72 let (frontmatter_result, body) = parse_frontmatter(content);
73 let body_start = content.len() - body.len();
74 let (frontmatter, frontmatter_error) = match frontmatter_result {
75 Ok(fm) => (fm, None),
76 Err(e) => (None, Some(e)),
77 };
78 let frontmatter_options = frontmatter.as_ref().and_then(|fm| fm.options.clone());
79 let frontmatter_integral_name_memory = frontmatter
81 .as_ref()
82 .and_then(|fm| fm.integral_name_memory.clone())
83 .filter(|_| {
84 frontmatter_options
85 .as_ref()
86 .and_then(|o| o.integral_name_memory.as_ref())
87 .is_none()
88 });
89 let frontmatter_org_abbreviation_memory = frontmatter
90 .and_then(|fm| fm.org_abbreviation_memory)
91 .filter(|_| {
92 frontmatter_options
93 .as_ref()
94 .and_then(|o| o.org_abbreviation_memory.as_ref())
95 .is_none()
96 });
97
98 let (raw_note_refs, manual_note_labels, footnote_ranges) = scan_manual_notes_markdown(body);
99
100 let mut seen_labels = HashSet::new();
105 let mut manual_note_order = Vec::new();
106 let manual_note_references: Vec<ManualNoteReference> = raw_note_refs
107 .into_iter()
108 .map(|r| ManualNoteReference {
109 label: r.label.clone(),
110 start: r.start,
111 })
112 .inspect(|r| {
113 if seen_labels.insert(r.label.clone()) {
114 manual_note_order.push(r.label.clone());
115 }
116 })
117 .collect();
118
119 let citations = find_citations(body, locale)
120 .into_iter()
121 .map(|(start, end, citation)| {
122 let placement = footnote_placement(start, end, &footnote_ranges);
123 ParsedCitation {
124 start,
125 end,
126 citation,
127 placement,
128 structure: CitationStructure::default(),
129 }
130 })
131 .collect();
132
133 ParsedDocument {
134 citations,
135 manual_note_order,
136 manual_note_references,
137 manual_note_labels,
138 bibliography_blocks: Vec::new(),
139 frontmatter_groups: None,
140 frontmatter_integral_name_memory,
141 frontmatter_org_abbreviation_memory,
142 frontmatter_options,
143 frontmatter_error,
144 body_start,
145 }
146 }
147}
148
149fn scan_manual_notes_markdown(
160 content: &str,
161) -> (
162 Vec<ManualNoteReference>,
163 HashSet<String>,
164 Vec<FootnoteRange>,
165) {
166 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
167
168 let opts = Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
169 let mut manual_note_references = Vec::new();
170 let mut manual_note_labels = HashSet::new();
171 let mut footnote_ranges = Vec::new();
172 let mut footnote_stack: Vec<(String, usize)> = Vec::new();
173
174 for (event, range) in Parser::new_ext(content, opts).into_offset_iter() {
175 match event {
176 Event::FootnoteReference(label) if footnote_stack.is_empty() => {
177 manual_note_references.push(ManualNoteReference {
178 label: label.to_string(),
179 start: range.start,
180 });
181 manual_note_labels.insert(label.to_string());
182 }
183 Event::Start(Tag::FootnoteDefinition(label)) => {
184 manual_note_labels.insert(label.to_string());
185 footnote_stack.push((label.to_string(), range.start));
186 }
187 Event::End(TagEnd::FootnoteDefinition) => {
188 if let Some((open_label, content_start)) = footnote_stack.pop() {
189 footnote_ranges.push(FootnoteRange {
190 label: open_label,
191 content: content_start..range.end,
192 });
193 }
194 }
195 _ => {}
196 }
197 }
198
199 (manual_note_references, manual_note_labels, footnote_ranges)
200}
201
202fn footnote_placement(start: usize, end: usize, ranges: &[FootnoteRange]) -> CitationPlacement {
205 ranges
206 .iter()
207 .find(|fr| fr.content.start <= start && end <= fr.content.end)
208 .map_or(CitationPlacement::InlineProse, |fr| {
209 CitationPlacement::ManualFootnote {
210 label: fr.label.clone(),
211 }
212 })
213}
214
215#[allow(
216 clippy::string_slice,
217 clippy::unreachable,
218 reason = "Markdown scanning logic"
219)]
220fn find_citations(content: &str, locale: &Locale) -> Vec<(usize, usize, Citation)> {
221 let mut results = Vec::new();
222 let mut offset = 0;
223
224 while offset < content.len() {
225 let remaining = &content[offset..];
226 let next_at = remaining.find('@');
227 let next_bracket = remaining.find('[');
228
229 let (relative_start, kind) = match (next_at, next_bracket) {
230 (Some(at), Some(bracket)) if bracket <= at => (bracket, ScanKind::Bracket),
231 (Some(at), Some(bracket)) if at < bracket => (at, ScanKind::Textual),
232 (Some(at), None) => (at, ScanKind::Textual),
233 (None, Some(bracket)) => (bracket, ScanKind::Bracket),
234 (None, None) => break,
235 _ => unreachable!(),
236 };
237
238 let start = offset + relative_start;
239 let candidate = &content[start..];
240
241 let parsed = match kind {
242 ScanKind::Bracket => parse_bracketed_citation(candidate, locale),
243 ScanKind::Textual => parse_textual_citation(content, start, locale),
244 };
245
246 if let Some((consumed, citation)) = parsed {
247 results.push((start, start + consumed, citation));
248 offset = start + consumed;
249 } else if matches!(kind, ScanKind::Bracket) {
250 offset = start + candidate.find(']').map_or(1, |idx| idx + 1);
251 } else {
252 offset = start + 1;
253 }
254 }
255
256 results
257}
258
259#[derive(Debug, Clone, Copy)]
260enum ScanKind {
261 Bracket,
262 Textual,
263}
264
265#[allow(clippy::string_slice, reason = "Brackets and @ are 1-byte ASCII")]
266fn parse_bracketed_citation(input: &str, locale: &Locale) -> Option<(usize, Citation)> {
267 if !input.starts_with('[') {
268 return None;
269 }
270
271 let closing = input.find(']')?;
272 let inner = input[1..closing].trim();
273 if inner.is_empty() || !inner.contains('@') {
274 return None;
275 }
276
277 let mut items = Vec::new();
278 let mut suppress_author = None;
279
280 for segment in inner.split(';') {
281 let (item, suppress) = parse_bracketed_item(segment, locale)?;
282 if let Some(existing) = suppress_author {
283 if existing != suppress {
284 return None;
285 }
286 } else {
287 suppress_author = Some(suppress);
288 }
289 items.push(item);
290 }
291
292 Some((
293 closing + 1,
294 Citation {
295 items,
296 suppress_author: suppress_author.unwrap_or(false),
297 ..Default::default()
298 },
299 ))
300}
301
302#[allow(
303 clippy::string_slice,
304 clippy::indexing_slicing,
305 reason = "Citations are ASCII-heavy; indices from find() are on char boundaries"
306)]
307fn parse_bracketed_item(segment: &str, locale: &Locale) -> Option<(CitationItem, bool)> {
308 let segment = segment.trim();
309 let at_pos = segment.find('@')?;
310 let mut suppress_author = false;
311 let prefix_end = if at_pos > 0 && segment.as_bytes()[at_pos - 1] == b'-' {
312 suppress_author = true;
313 at_pos - 1
314 } else {
315 at_pos
316 };
317
318 let prefix = normalize_prefix(&segment[..prefix_end]);
319 let after_at = &segment[at_pos + 1..];
320 let key_end = cite_key_len(after_at)?;
321 let key = &after_at[..key_end];
322 let remainder = after_at[key_end..].trim_start();
323
324 let mut item = CitationItem {
325 id: key.to_string(),
326 prefix,
327 ..Default::default()
328 };
329
330 if let Some(rest) = remainder.strip_prefix(',') {
331 let rest = rest.trim();
332 if !rest.is_empty() {
333 item.locator = normalize_locator_text(rest, locale);
334 if item.locator.is_none() {
335 item.suffix = Some(rest.to_string());
336 }
337 }
338 } else if !remainder.is_empty() {
339 item.suffix = Some(remainder.trim().to_string());
340 }
341
342 Some((item, suppress_author))
343}
344
345#[allow(clippy::string_slice, reason = "@ and indices from find() are safe")]
346fn parse_textual_citation(
347 content: &str,
348 start: usize,
349 locale: &Locale,
350) -> Option<(usize, Citation)> {
351 if !is_valid_textual_start(content, start) {
352 return None;
353 }
354
355 let after_at = &content[start + 1..];
356 let key_end = cite_key_len(after_at)?;
357 let key = &after_at[..key_end];
358 let mut consumed = 1 + key_end;
359
360 let mut item = CitationItem {
361 id: key.to_string(),
362 ..Default::default()
363 };
364
365 let trailing = &content[start + consumed..];
366 if let Some((locator_consumed, locator)) = parse_textual_locator_suffix(trailing, locale) {
367 item.locator = Some(locator);
368 consumed += locator_consumed;
369 }
370
371 Some((
372 consumed,
373 Citation {
374 mode: CitationMode::Integral,
375 items: vec![item],
376 ..Default::default()
377 },
378 ))
379}
380
381#[allow(clippy::string_slice, reason = "Brackets and @ are 1-byte ASCII")]
382fn parse_textual_locator_suffix(
383 input: &str,
384 locale: &Locale,
385) -> Option<(usize, citum_schema::citation::CitationLocator)> {
386 let whitespace_len = input.len() - input.trim_start_matches(char::is_whitespace).len();
387 let rest = &input[whitespace_len..];
388 if !rest.starts_with('[') {
389 return None;
390 }
391
392 let closing = rest.find(']')?;
393 let inner = rest[1..closing].trim();
394 if inner.is_empty() || inner.contains('@') {
395 return None;
396 }
397
398 let locator = normalize_locator_text(inner, locale)?;
399 Some((whitespace_len + closing + 1, locator))
400}
401
402fn cite_key_len(input: &str) -> Option<usize> {
403 let len = input
404 .char_indices()
405 .take_while(
406 |(_, ch)| matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | ':' | '.'),
407 )
408 .map(|(idx, ch)| idx + ch.len_utf8())
409 .last()
410 .unwrap_or(0);
411
412 if len == 0 { None } else { Some(len) }
413}
414
415fn normalize_prefix(prefix: &str) -> Option<String> {
416 let trimmed = prefix.trim();
417 if trimmed.is_empty() {
418 None
419 } else {
420 Some(format!("{trimmed} "))
421 }
422}
423
424#[allow(clippy::string_slice, reason = "start index from find() is safe")]
425fn is_valid_textual_start(content: &str, start: usize) -> bool {
426 let prev = content[..start].chars().next_back();
427 !matches!(prev, Some(ch) if ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | '@'))
428}
429
430fn remap_nul_tokens(s: &str) -> (String, Vec<(String, String)>) {
439 let mut result = String::with_capacity(s.len());
440 let mut map: Vec<(String, String)> = Vec::new();
441 let mut outside = true;
442 let mut token_body = String::new();
443 for ch in s.chars() {
444 if ch == '\x00' {
445 if outside {
446 token_body.clear();
448 } else {
449 let idx = map.len();
451 let comment = format!("<!--CITUM-TOKEN-{idx}-->");
452 let original = format!("\x00{token_body}\x00");
453 result.push_str(&comment);
454 map.push((comment, original));
455 }
456 outside = !outside;
457 } else if outside {
458 result.push(ch);
459 } else {
460 token_body.push(ch);
461 }
462 }
463 (result, map)
464}
465
466#[cfg(test)]
467#[allow(
468 clippy::unwrap_used,
469 clippy::expect_used,
470 clippy::panic,
471 clippy::indexing_slicing,
472 clippy::string_slice,
473 clippy::todo,
474 clippy::unimplemented,
475 clippy::unreachable,
476 clippy::get_unwrap,
477 reason = "Panicking is acceptable and often desired in tests."
478)]
479mod tests {
480 use super::*;
481 use citum_schema::citation::{CitationLocator, LocatorType};
482
483 #[test]
484 fn test_parse_bracketed_multi_cite() {
485 let parser = MarkdownParser;
486 let citations =
487 parser.parse_citations("See [@kuhn1962; @watson1953, ch. 2].", &Locale::en_us());
488
489 assert_eq!(citations.len(), 1);
490 let (_, _, citation) = &citations[0];
491 assert_eq!(citation.items.len(), 2);
492 assert_eq!(citation.items[0].id, "kuhn1962");
493 assert_eq!(
494 citation.items[1].locator,
495 Some(CitationLocator::single(LocatorType::Chapter, "2"))
496 );
497 }
498
499 #[test]
500 fn test_parse_bracketed_prefix_and_suppress_author() {
501 let parser = MarkdownParser;
502 let citations = parser.parse_citations("[see -@kuhn1962, p. 10]", &Locale::en_us());
503
504 assert_eq!(citations.len(), 1);
505 let (_, _, citation) = &citations[0];
506 assert!(citation.suppress_author);
507 assert_eq!(citation.items[0].prefix.as_deref(), Some("see "));
508 assert_eq!(
509 citation.items[0].locator,
510 Some(CitationLocator::single(LocatorType::Page, "10"))
511 );
512 }
513
514 #[test]
515 fn test_parse_textual_citation() {
516 let parser = MarkdownParser;
517 let citations = parser.parse_citations(
518 "Kuhn argued that @kuhn1962 changed science.",
519 &Locale::en_us(),
520 );
521
522 assert_eq!(citations.len(), 1);
523 let (_, _, citation) = &citations[0];
524 assert_eq!(citation.mode, CitationMode::Integral);
525 assert_eq!(citation.items[0].id, "kuhn1962");
526 }
527
528 #[test]
529 fn test_parse_textual_citation_with_locator_suffix() {
530 let parser = MarkdownParser;
531 let citations =
532 parser.parse_citations("@kuhn1962 [p. 10] argues this point.", &Locale::en_us());
533
534 assert_eq!(citations.len(), 1);
535 let (_, _, citation) = &citations[0];
536 assert_eq!(citation.mode, CitationMode::Integral);
537 assert_eq!(
538 citation.items[0].locator,
539 Some(CitationLocator::single(LocatorType::Page, "10"))
540 );
541 }
542
543 #[test]
544 fn given_frontmatter_when_parse_document_then_citation_offsets_are_body_relative() {
545 let parser = MarkdownParser;
546 let content = "---\ntitle: T\n---\n\nText [@kuhn1962] here.";
547 let parsed = parser.parse_document(content, &Locale::en_us());
548
549 assert_eq!(parsed.citations.len(), 1);
550 let citation = &parsed.citations[0];
551 let body = &content[parsed.body_start..];
552
553 assert!(
555 citation.end <= body.len(),
556 "citation end {} must be within body of length {}",
557 citation.end,
558 body.len()
559 );
560 assert_eq!(&body[citation.start..citation.end], "[@kuhn1962]");
561 }
562
563 #[test]
564 fn test_parse_document_marks_citations_as_inline_prose() {
565 let parser = MarkdownParser;
566 let parsed = parser.parse_document("Text [@kuhn1962].", &Locale::en_us());
567
568 assert_eq!(parsed.citations.len(), 1);
569 assert_eq!(
570 parsed.citations[0].placement,
571 CitationPlacement::InlineProse
572 );
573 assert!(parsed.manual_note_order.is_empty());
574 assert!(parsed.bibliography_blocks.is_empty());
575 }
576
577 #[test]
578 fn test_does_not_parse_email_address() {
579 let parser = MarkdownParser;
580 let citations =
581 parser.parse_citations("Contact test@example.com for details.", &Locale::en_us());
582
583 assert!(citations.is_empty());
584 }
585
586 #[test]
587 fn test_unsupported_bracket_cluster_does_not_fall_back_to_textual_citations() {
588 let parser = MarkdownParser;
589 let citations =
590 parser.parse_citations("Mixed [@kuhn1962; -@watson1953] cluster.", &Locale::en_us());
591
592 assert!(citations.is_empty());
593 }
594
595 #[test]
596 fn given_markdown_body_when_finalize_html_output_then_markup_is_converted_to_html() {
597 let parser = MarkdownParser;
598 let input = "**bold** and _em_ text.";
599 let output = parser.finalize_html_output(input);
600 assert!(
601 output.contains("<strong>bold</strong>"),
602 "expected <strong>bold</strong> in: {output}"
603 );
604 assert!(
605 output.contains("<em>em</em>"),
606 "expected <em>em</em> in: {output}"
607 );
608 }
609
610 #[test]
611 fn given_markdown_with_nul_tokens_when_finalize_html_output_then_tokens_survive_conversion() {
612 let parser = MarkdownParser;
613 let token = "\x00CITUMHTMLINLINETOKEN0\x00";
616 let input = format!("Some prose with {token} inline.");
617 let output = parser.finalize_html_output(&input);
618 assert!(
619 output.contains(token),
620 "NUL token must survive pulldown-cmark conversion; output: {output}"
621 );
622 }
623
624 #[test]
625 fn given_markdown_blockquote_when_finalize_html_output_then_blockquote_element_emitted() {
626 let parser = MarkdownParser;
627 let input = "> block quote with *italic* text";
628 let output = parser.finalize_html_output(input);
629 assert!(
630 output.contains("<blockquote>"),
631 "expected <blockquote> in: {output}"
632 );
633 assert!(
634 output.contains("<em>italic</em>"),
635 "expected <em>italic</em> in: {output}"
636 );
637 }
638
639 #[test]
640 fn given_markdown_pipe_table_when_finalize_html_output_then_table_element_emitted() {
641 let parser = MarkdownParser;
642 let input = "| A | B |\n|---|---|\n| 1 | 2 |";
643 let output = parser.finalize_html_output(input);
644 assert!(
645 output.contains("<table>"),
646 "pipe table should render as <table>: {output}"
647 );
648 }
649
650 #[test]
651 fn given_markdown_footnote_def_when_finalize_html_output_then_footnote_rendered() {
652 let parser = MarkdownParser;
653 let input = "Text[^1].\n\n[^1]: A note.";
654 let output = parser.finalize_html_output(input);
655 assert!(
656 output.contains("footnote") || output.contains("fn1"),
657 "footnote definition should produce HTML footnote markup: {output}"
658 );
659 }
660
661 #[test]
662 fn given_citation_inside_footnote_def_when_parse_document_then_placement_is_manual_footnote() {
663 let parser = MarkdownParser;
664 let doc = "See note[^1].\n\n[^1]: See [@kuhn1962].";
666 let parsed = parser.parse_document(doc, &Locale::en_us());
667
668 assert_eq!(parsed.citations.len(), 1, "one citation expected");
669 assert!(
670 matches!(
671 parsed.citations[0].placement,
672 CitationPlacement::ManualFootnote { .. }
673 ),
674 "citation inside [^n]: block should be ManualFootnote, got: {:?}",
675 parsed.citations[0].placement
676 );
677 assert!(
678 parsed.manual_note_labels.contains("1"),
679 "footnote label '1' should be tracked: {:?}",
680 parsed.manual_note_labels
681 );
682 assert_eq!(parsed.manual_note_order, vec!["1".to_string()]);
683 }
684
685 #[test]
686 fn given_citation_in_prose_when_parse_document_then_placement_is_inline_prose() {
687 let parser = MarkdownParser;
688 let doc = "As shown by [@kuhn1962], the method works.\n\n[^1]: Unrelated note.";
689 let parsed = parser.parse_document(doc, &Locale::en_us());
690
691 assert_eq!(parsed.citations.len(), 1);
692 assert!(
693 matches!(
694 parsed.citations[0].placement,
695 CitationPlacement::InlineProse
696 ),
697 "prose citation should be InlineProse: {:?}",
698 parsed.citations[0].placement
699 );
700 }
701
702 #[test]
703 fn given_multiple_footnotes_when_parse_document_then_note_order_is_first_reference_order() {
704 let parser = MarkdownParser;
705 let doc = "First[^b] then[^a].\n\n[^a]: [@kuhn1962].\n\n[^b]: [@smith2010].";
706 let parsed = parser.parse_document(doc, &Locale::en_us());
707
708 assert_eq!(
710 parsed.manual_note_order,
711 vec!["b".to_string(), "a".to_string()]
712 );
713 assert_eq!(parsed.citations.len(), 2);
714 for c in &parsed.citations {
715 assert!(
716 matches!(c.placement, CitationPlacement::ManualFootnote { .. }),
717 "both citations are inside footnote definitions: {:?}",
718 c.placement
719 );
720 }
721 }
722}