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