1mod blocks;
7mod container;
8mod inline;
9mod layout;
10mod options;
11mod source;
12
13#[cfg(test)]
14mod tests;
15
16pub use container::TldrDirectiveError;
17pub(crate) use options::is_semantic_entry_rejection_code;
18
19use std::{
20 collections::{BTreeMap, HashMap, HashSet},
21 error::Error,
22 fmt,
23 ops::Range,
24};
25
26use mant_ir::{
27 Block, Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, Inline, ParserInfo,
28 Section, SourceFormat, TldrDocument, TldrOrigin, validate_document,
29 visit::{self, VisitMut},
30};
31use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
32
33use self::{
34 blocks::parse_block,
35 container::split_markdown,
36 inline::{inline_text, parse_inlines},
37 layout::normalize_markdown_layout,
38 options::{extract_entry_directives, normalize_entry_lists},
39 source::MarkdownSource,
40};
41use crate::text_safety::mask_terminal_controls;
42use crate::{
43 projection::DOCUMENT_ROOT_ID,
44 tldr::{TldrPageLocation, TldrParseError, parse_tldr_page},
45};
46
47type SpannedEvent<'a> = (Event<'a>, Range<usize>);
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ParsedMarkdown {
52 pub document: Document,
54 pub tldr: Option<TldrDocument>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum MarkdownParseError {
61 TldrDirective(TldrDirectiveError),
63 TldrPage(TldrParseError),
65}
66
67impl fmt::Display for MarkdownParseError {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::TldrDirective(error) => error.fmt(formatter),
71 Self::TldrPage(error) => write!(formatter, "invalid embedded tldr page: {error}"),
72 }
73 }
74}
75
76impl Error for MarkdownParseError {}
77
78pub fn parse_markdown(
91 source_text: &str,
92 source_path: Option<String>,
93) -> Result<ParsedMarkdown, MarkdownParseError> {
94 let mut sanitize_diagnostics = Vec::new();
95 let sanitized = sanitize_source(source_text, &mut sanitize_diagnostics);
96 let source_text = sanitized.as_deref().unwrap_or(source_text);
97 let parts = split_markdown(source_text).map_err(MarkdownParseError::TldrDirective)?;
98 let tldr = parts
99 .tldr
100 .map(|source| {
101 parse_tldr_page(
102 source,
103 TldrPageLocation {
104 platform: "embedded".to_owned(),
105 language: "und".to_owned(),
106 source_path: source_path.clone().unwrap_or_else(|| "<stdin>".to_owned()),
107 },
108 )
109 .map(|mut page| {
110 page.origin = TldrOrigin::Embedded;
111 page
112 })
113 .map_err(MarkdownParseError::TldrPage)
114 })
115 .transpose()?;
116 let mut entry_diagnostics = Vec::new();
117 let (masked_document, declarations) =
118 extract_entry_directives(parts.document.as_ref(), &mut entry_diagnostics);
119 let document_source = masked_document
120 .as_deref()
121 .unwrap_or_else(|| parts.document.as_ref());
122 let mut document = parse_document_with_entries(
123 document_source,
124 source_path,
125 declarations,
126 &mut entry_diagnostics,
127 );
128 if !entry_diagnostics.is_empty() {
129 entry_diagnostics.extend(std::mem::take(&mut document.diagnostics));
130 document.diagnostics = entry_diagnostics;
131 }
132 if !sanitize_diagnostics.is_empty() {
133 sanitize_diagnostics.extend(std::mem::take(&mut document.diagnostics));
134 document.diagnostics = sanitize_diagnostics;
135 }
136 Ok(ParsedMarkdown { document, tldr })
137}
138
139fn sanitize_source(source_text: &str, diagnostics: &mut Vec<Diagnostic>) -> Option<String> {
145 let bom = source_text.starts_with('\u{feff}');
146 let rest = if bom {
147 &source_text['\u{feff}'.len_utf8()..]
148 } else {
149 source_text
150 };
151 let (masked, controls) = mask_terminal_controls(rest);
152 if !bom && masked.is_none() {
153 return None;
154 }
155
156 let mut sanitized = String::with_capacity(source_text.len());
157 if bom {
158 sanitized.push_str(" ");
159 }
160 sanitized.push_str(masked.as_deref().unwrap_or(rest));
161
162 if bom {
163 diagnostics.push(Diagnostic {
164 level: DiagnosticLevel::Warning,
165 code: Some("markdown.byte-order-mark".to_owned()),
166 message: "masked a leading byte-order mark".to_owned(),
167 source: None,
168 });
169 }
170 if controls > 0 {
171 diagnostics.push(Diagnostic {
172 level: DiagnosticLevel::Warning,
173 code: Some("markdown.control-characters".to_owned()),
174 message: format!("masked {controls} terminal-unsafe control character(s)"),
175 source: None,
176 });
177 }
178 Some(sanitized)
179}
180
181#[cfg(test)]
183fn parse_document(source_text: &str, source_path: Option<String>) -> Document {
184 let mut diagnostics = Vec::new();
185 parse_document_with_entries(source_text, source_path, BTreeMap::new(), &mut diagnostics)
186}
187
188fn parse_document_with_entries(
189 source_text: &str,
190 source_path: Option<String>,
191 mut declarations: BTreeMap<u32, options::EntryDeclaration>,
192 entry_diagnostics: &mut Vec<Diagnostic>,
193) -> Document {
194 let source = MarkdownSource::new(source_text);
195 let ParsedDocumentStructure {
196 diagnostics,
197 mut root_blocks,
198 flat_sections,
199 mut ids,
200 title,
201 document_title_id,
202 } = lower_document_structure(source_text, &source);
203 let mut sections = nest_sections(flat_sections);
204 let extracted_title = extract_document_title(
205 &mut root_blocks,
206 &mut sections,
207 document_title_id.as_deref(),
208 );
209 if extracted_title {
210 let replacement = if root_blocks.is_empty() {
211 sections.first().map(|section| section.id.as_str())
212 } else {
213 Some(DOCUMENT_ROOT_ID)
214 };
215 ids.remap_target(document_title_id.as_deref(), replacement);
216 }
217 normalize_markdown_layout(&source, &mut root_blocks, &mut sections);
218 normalize_entry_lists(&mut root_blocks, &mut declarations, entry_diagnostics);
219 normalize_section_entries(&mut sections, &mut declarations, entry_diagnostics);
220 for declaration in declarations.into_values() {
221 entry_diagnostics.push(Diagnostic {
222 level: DiagnosticLevel::Warning,
223 code: Some("markdown.semantic-entry-list".to_owned()),
224 message: "semantic-entry directive did not resolve to a Markdown bullet list"
225 .to_owned(),
226 source: Some(declaration.source),
227 });
228 }
229 let retained_targets = crate::definitions::identify_definitions(
230 &mut root_blocks,
231 &mut sections,
232 &ids.targets.values().cloned().collect(),
236 source_path.as_deref(),
237 );
238 for target in retained_targets {
239 ids.targets.insert(target.clone(), target);
240 }
241 entry_diagnostics.extend(crate::projection::semantic_selector_diagnostics(
242 &root_blocks,
243 §ions,
244 "markdown",
245 ));
246 let mut document = Document {
247 parser: Some(markdown_parser()),
248 source: DocumentSource {
249 format: SourceFormat::Markdown,
250 path: source_path,
251 },
252 meta: DocumentMeta {
253 title,
254 ..DocumentMeta::default()
255 },
256 diagnostics,
257 blocks: root_blocks,
258 sections,
259 };
260 LocalLinkResolver::new(&ids.targets).visit_document_mut(&mut document);
261 document.diagnostics.extend(validate_document(&document));
262 document
263}
264
265struct ParsedDocumentStructure {
266 diagnostics: Vec<Diagnostic>,
267 root_blocks: Vec<Block>,
268 flat_sections: Vec<FlatSection>,
269 ids: SectionIds,
270 title: Option<String>,
271 document_title_id: Option<String>,
272}
273
274fn lower_document_structure(
276 source_text: &str,
277 source: &MarkdownSource<'_>,
278) -> ParsedDocumentStructure {
279 let parser = Parser::new_ext(source_text, markdown_options());
280 let mut cursor = EventCursor::new(parser.into_offset_iter().collect());
281 let mut diagnostics = Vec::new();
282 let mut root_blocks = Vec::new();
283 let mut flat_sections = Vec::new();
284 let mut ids = SectionIds::default();
285 let mut title = None;
286 let mut document_title_id = None;
287 let mut saw_heading = false;
288
289 while let Some((event, range)) = cursor.peek().cloned() {
290 if let Event::Start(Tag::Heading {
291 level,
292 id: explicit_id,
293 ..
294 }) = event
295 {
296 let _ = cursor.next();
297 let (mut children, end) = parse_inlines(
298 &mut cursor,
299 source,
300 &mut diagnostics,
301 TagEnd::Heading(level),
302 );
303 let explicit_id = explicit_id
309 .map(pulldown_cmark::CowStr::into_string)
310 .or_else(|| take_explicit_heading_id(&mut children));
311 let heading = inline_text(&children);
312 if heading.is_empty() {
313 diagnostics.push(Diagnostic {
314 level: DiagnosticLevel::Warning,
315 code: Some("markdown.empty-heading".to_owned()),
316 message: "ignored an empty Markdown heading".to_owned(),
317 source: Some(source.span(&(range.start..end))),
318 });
319 continue;
320 }
321 let is_document_title = !saw_heading && level == HeadingLevel::H1;
322 saw_heading = true;
323 if is_document_title {
324 title = Some(heading.clone());
325 }
326 let id = ids.allocate(&heading, explicit_id.as_deref());
327 if is_document_title {
328 document_title_id = Some(id.clone());
329 }
330 flat_sections.push(FlatSection {
331 level: heading_level(level),
332 is_document_title,
333 section: Section {
334 id: id.into(),
335 title: heading.clone(),
336 spacing_before_lines: u16::from(!flat_sections.is_empty()),
337 blocks: Vec::new(),
338 children: Vec::new(),
339 source: Some(source.span(&(range.start..end))),
340 },
341 });
342 continue;
343 }
344
345 let Some(block) = parse_block(&mut cursor, source, &mut diagnostics) else {
346 continue;
347 };
348 if let Some(current) = flat_sections.last_mut() {
349 current.section.blocks.push(block);
350 } else {
351 root_blocks.push(block);
352 }
353 }
354
355 ParsedDocumentStructure {
356 diagnostics,
357 root_blocks,
358 flat_sections,
359 ids,
360 title,
361 document_title_id,
362 }
363}
364
365fn markdown_parser() -> ParserInfo {
366 ParserInfo {
367 name: "pulldown-cmark".to_owned(),
368 version: "0.13".to_owned(),
369 }
370}
371
372fn normalize_section_entries(
373 sections: &mut [Section],
374 declarations: &mut BTreeMap<u32, options::EntryDeclaration>,
375 diagnostics: &mut Vec<Diagnostic>,
376) {
377 for section in sections {
378 normalize_entry_lists(&mut section.blocks, declarations, diagnostics);
379 normalize_section_entries(&mut section.children, declarations, diagnostics);
380 }
381}
382
383fn markdown_options() -> Options {
384 Options::ENABLE_TABLES
385 | Options::ENABLE_FOOTNOTES
386 | Options::ENABLE_STRIKETHROUGH
387 | Options::ENABLE_TASKLISTS
388 | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
389 | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
390 | Options::ENABLE_MATH
391 | Options::ENABLE_GFM
392 | Options::ENABLE_DEFINITION_LIST
393 | Options::ENABLE_SUPERSCRIPT
394 | Options::ENABLE_SUBSCRIPT
395 | Options::ENABLE_WIKILINKS
396}
397
398fn take_explicit_heading_id(children: &mut Vec<Inline>) -> Option<String> {
399 let (id, empty) = {
400 let Inline::Text { value } = children.last_mut()? else {
401 return None;
402 };
403 let trimmed = value.trim_end();
404 let opening = trimmed.rfind("{#")?;
405 if !trimmed.ends_with('}') {
406 return None;
407 }
408 if opening != 0
409 && !trimmed[..opening]
410 .chars()
411 .next_back()
412 .is_some_and(char::is_whitespace)
413 {
414 return None;
415 }
416 let id = trimmed
417 .get(opening + 2..trimmed.len().checked_sub(1)?)?
418 .to_owned();
419 if id.is_empty()
420 || id.bytes().any(|byte| {
421 byte.is_ascii_whitespace() || matches!(byte, b'{' | b'}' | b'\\' | b'<' | b'>')
422 })
423 {
424 return None;
425 }
426 let title_end = trimmed[..opening].trim_end().len();
427 value.truncate(title_end);
428 (id, value.is_empty())
429 };
430 if empty {
431 children.pop();
432 }
433 Some(id)
434}
435
436fn heading_level(level: HeadingLevel) -> u8 {
437 match level {
438 HeadingLevel::H1 => 1,
439 HeadingLevel::H2 => 2,
440 HeadingLevel::H3 => 3,
441 HeadingLevel::H4 => 4,
442 HeadingLevel::H5 => 5,
443 HeadingLevel::H6 => 6,
444 }
445}
446
447fn extract_document_title(
449 root_blocks: &mut Vec<Block>,
450 sections: &mut Vec<Section>,
451 document_title_id: Option<&str>,
452) -> bool {
453 let Some(document_title_id) = document_title_id else {
454 return false;
455 };
456 if sections.first().map(|section| section.id.as_str()) != Some(document_title_id) {
457 return false;
458 }
459 let title = sections.remove(0);
460 root_blocks.extend(title.blocks);
461 sections.splice(0..0, title.children);
462 true
463}
464
465struct FlatSection {
466 level: u8,
467 is_document_title: bool,
468 section: Section,
469}
470
471fn nest_sections(flat: Vec<FlatSection>) -> Vec<Section> {
472 let mut roots = Vec::new();
473 let mut stack: Vec<FlatSection> = Vec::new();
474
475 for next in flat {
476 while stack
477 .last()
478 .is_some_and(|current| current.is_document_title || current.level >= next.level)
479 {
480 attach_completed(&mut stack, &mut roots);
481 }
482 stack.push(next);
483 }
484 while !stack.is_empty() {
485 attach_completed(&mut stack, &mut roots);
486 }
487 roots
488}
489
490fn attach_completed(stack: &mut Vec<FlatSection>, roots: &mut Vec<Section>) {
491 let completed = stack.pop().expect("caller checks non-empty stack").section;
492 if let Some(parent) = stack.last_mut() {
493 parent.section.children.push(completed);
494 } else {
495 roots.push(completed);
496 }
497}
498
499#[derive(Default)]
500struct SectionIds {
501 counts: HashMap<String, usize>,
502 assigned: HashSet<String>,
503 targets: HashMap<String, String>,
504}
505
506impl SectionIds {
507 fn allocate(&mut self, title: &str, explicit: Option<&str>) -> String {
508 let explicit = explicit
509 .map(str::trim)
510 .filter(|value| !value.is_empty())
511 .map(ToOwned::to_owned);
512 let base = explicit.clone().unwrap_or_else(|| slug(title));
513 let base = if base.is_empty() {
514 "section".to_owned()
515 } else if crate::projection::is_reserved_selector(&base) {
516 format!("{base}-section")
519 } else {
520 base
521 };
522 let count = self.counts.entry(base.clone()).or_default();
527 let id = loop {
528 *count += 1;
529 let candidate = if *count == 1 {
530 base.clone()
531 } else {
532 format!("{base}-{}", *count)
533 };
534 if self.assigned.insert(candidate.clone()) {
535 break candidate;
536 }
537 };
538 self.targets
542 .entry(base.clone())
543 .or_insert_with(|| id.clone());
544 if let Some(explicit) = explicit {
548 self.targets.entry(explicit).or_insert_with(|| id.clone());
549 }
550 self.targets
551 .entry(slug(title))
552 .or_insert_with(|| id.clone());
553 self.targets.insert(id.clone(), id.clone());
554 id
555 }
556
557 fn remap_target(&mut self, current: Option<&str>, replacement: Option<&str>) {
558 let Some(current) = current else {
559 return;
560 };
561 if let Some(replacement) = replacement {
562 for target in self.targets.values_mut() {
563 if target == current {
564 replacement.clone_into(target);
565 }
566 }
567 } else {
568 self.targets.retain(|_, target| target != current);
569 }
570 }
571}
572
573fn slug(value: &str) -> String {
574 let mut output = String::new();
575 let mut separator = false;
576 for character in value.chars().flat_map(char::to_lowercase) {
577 if character.is_alphanumeric() || character == '_' {
578 if separator && !output.is_empty() {
579 output.push('-');
580 }
581 separator = false;
582 output.push(character);
583 } else {
584 separator = true;
585 }
586 }
587 output.trim_matches('-').to_owned()
588}
589
590struct LocalLinkResolver<'targets> {
591 targets: &'targets HashMap<String, String>,
592}
593
594impl<'targets> LocalLinkResolver<'targets> {
595 fn new(targets: &'targets HashMap<String, String>) -> Self {
596 Self { targets }
597 }
598}
599
600impl VisitMut for LocalLinkResolver<'_> {
601 fn visit_inline_mut(&mut self, inline: &mut Inline) {
602 if let Inline::Link {
603 target: mant_ir::LinkTarget::Section { id },
604 ..
605 } = inline
606 {
607 let lookup = id.trim().trim_start_matches('#');
608 if let Some(resolved) = self
609 .targets
610 .get(lookup)
611 .or_else(|| self.targets.get(&slug(lookup)))
612 {
613 *id = resolved.as_str().into();
614 }
615 }
616 visit::walk_inline_mut(self, inline);
617 }
618}
619
620pub(super) struct EventCursor<'a> {
621 events: Vec<SpannedEvent<'a>>,
622 position: usize,
623 depth: usize,
624}
625
626const MAX_NESTING_DEPTH: usize = 64;
632
633impl<'a> EventCursor<'a> {
634 fn new(events: Vec<SpannedEvent<'a>>) -> Self {
635 Self {
636 events,
637 position: 0,
638 depth: 0,
639 }
640 }
641
642 pub(super) fn try_descend(&mut self) -> bool {
644 if self.depth >= MAX_NESTING_DEPTH {
645 return false;
646 }
647 self.depth += 1;
648 true
649 }
650
651 pub(super) fn ascend(&mut self) {
652 self.depth = self.depth.saturating_sub(1);
653 }
654
655 pub(super) fn peek(&self) -> Option<&SpannedEvent<'a>> {
656 self.events.get(self.position)
657 }
658
659 pub(super) fn next(&mut self) -> Option<SpannedEvent<'a>> {
660 let event = self.events.get(self.position)?.clone();
661 self.position += 1;
662 Some(event)
663 }
664
665 pub(super) fn consume_balanced(&mut self, start: Range<usize>) -> Range<usize> {
667 let mut depth = 1usize;
668 let mut end = start.end;
669 while let Some((event, range)) = self.next() {
670 end = range.end;
671 match event {
672 Event::Start(_) => depth = depth.saturating_add(1),
673 Event::End(_) => {
674 depth = depth.saturating_sub(1);
675 if depth == 0 {
676 break;
677 }
678 }
679 _ => {}
680 }
681 }
682 start.start..end
683 }
684
685 pub(super) fn subtree_contains_task_marker(&self) -> bool {
686 let mut depth = 1usize;
687 for (event, _) in &self.events[self.position..] {
688 match event {
689 Event::TaskListMarker(_) => return true,
690 Event::Start(_) => depth = depth.saturating_add(1),
691 Event::End(_) => {
692 depth = depth.saturating_sub(1);
693 if depth == 0 {
694 return false;
695 }
696 }
697 _ => {}
698 }
699 }
700 false
701 }
702}