Skip to main content

mant_core/markdown/
mod.rs

1//! Parses a conservative Markdown subset into the shared document contract.
2//!
3//! Supported syntax becomes semantic AST nodes. Recognized extensions outside
4//! the subset remain visible as exact source text with an attached diagnostic.
5
6mod 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/// Complete result of parsing one ManT-flavoured Markdown input.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ParsedMarkdown {
44    pub document: MantDocument,
45    pub tldr: Option<TldrDocument>,
46}
47
48/// Invalid structure in `ManT`'s optional top-level Markdown extension.
49#[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
66/// Split `ManT`'s optional leading tldr directive from the Markdown document.
67///
68/// The directive must be the first non-empty construct and uses the existing
69/// tldr-pages Markdown dialect. The remaining source is parsed independently,
70/// so its first H1 remains document metadata rather than part of the preface.
71///
72/// # Errors
73///
74/// Returns [`MarkdownParseError`] for an unterminated directive or malformed
75/// embedded tldr page.
76pub 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
110/// Mask a leading BOM and terminal-unsafe control characters with spaces.
111///
112/// A BOM would hide the tldr directive and demote the first heading, while
113/// raw control characters would pass escape sequences through to terminals.
114/// Replacements keep every byte offset valid for source coordinates.
115fn 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
159/// Lower the ordinary document portion after extension extraction.
160fn 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
228/// Lower the Markdown event stream without imposing final document layout.
229fn 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
356/// A leading H1 names the document; it is metadata rather than manual content.
357fn 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            // Reserved selectors and bare tree paths would shadow this
425            // heading in excerpt selection; keep it addressable instead.
426            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        // Ambiguous human-facing keys resolve to the first section that
438        // claimed them, matching the bare slug this heading renders as its
439        // anchor. A later duplicate owns only its own disambiguated id.
440        self.targets
441            .entry(base.clone())
442            .or_insert_with(|| id.clone());
443        // Heading attributes are source-level link aliases. Preserve the
444        // original alias even when its final section ID had to move out of the
445        // selector namespace (`{#root}`, `{#2.1}`, or `{#2.1/o3}`).
446        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
582/// Recursion budget shared by nested block containers and inline spans.
583///
584/// Parsing recurses once per nesting level, so unbounded input depth would
585/// overflow the stack before any allocation limit applies. Subtrees beyond
586/// this depth are preserved as unsupported source text with a diagnostic.
587const 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    /// Reserve one nesting level; callers must pair with [`Self::ascend`].
599    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    /// Consume the remainder of a just-opened tag, including nested tags.
622    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}