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