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