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::{
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/// Complete result of parsing one ManT-flavoured Markdown input.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ParsedMarkdown {
50    pub document: MantDocument,
51    pub tldr: Option<TldrDocument>,
52}
53
54/// Invalid structure in `ManT`'s optional top-level Markdown extension.
55#[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
72/// Split `ManT`'s optional leading tldr preface from the Markdown document.
73///
74/// Invisible HTML comments delimit the preface so `CommonMark` renderers can
75/// present the enclosed tldr-pages Markdown without leaking extension syntax.
76/// It must be the first non-empty construct. The remaining source is parsed
77/// independently, so its first H1 remains document metadata rather than part
78/// of the preface.
79///
80/// # Errors
81///
82/// Returns [`MarkdownParseError`] for an unterminated preface or malformed
83/// embedded tldr page.
84pub 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
133/// Mask a leading BOM and terminal-unsafe control characters with spaces.
134///
135/// A BOM would hide the tldr opening marker and demote the first heading, while
136/// raw control characters would pass escape sequences through to terminals.
137/// Replacements keep every byte offset valid for source coordinates.
138fn 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/// Lower the ordinary document portion after extension extraction.
176#[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        &sections,
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
268/// Lower the Markdown event stream without imposing final document layout.
269fn 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
400/// A leading H1 names the document; it is metadata rather than manual content.
401fn 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            // Reserved selectors and bare tree paths would shadow this
470            // heading in excerpt selection; keep it addressable instead.
471            format!("{base}-section")
472        } else {
473            base
474        };
475        // Disambiguate on the final id, not the per-base count: `# Foo 2`
476        // slugs to base `foo-2`, which collides with the `foo-2` a second
477        // `# Foo` produces. Counting per base alone would hand both the same
478        // id, silently misattributing search ownership between them.
479        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        // Ambiguous human-facing keys resolve to the first section that
492        // claimed them, matching the bare slug this heading renders as its
493        // anchor. A later duplicate owns only its own disambiguated id.
494        self.targets
495            .entry(base.clone())
496            .or_insert_with(|| id.clone());
497        // Heading attributes are source-level link aliases. Preserve the
498        // original alias even when its final section ID had to move out of the
499        // selector namespace (`{#root}`, `{#2.1}`, or `{#2.1/o3}`).
500        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
636/// Recursion budget shared by nested block containers and inline spans.
637///
638/// Parsing recurses once per nesting level, so unbounded input depth would
639/// overflow the stack before any allocation limit applies. Subtrees beyond
640/// this depth are preserved as unsupported source text with a diagnostic.
641const 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    /// Reserve one nesting level; callers must pair with [`Self::ascend`].
653    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    /// Consume the remainder of a just-opened tag, including nested tags.
676    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}