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 preface from the Markdown document.
67///
68/// Invisible HTML comments delimit the preface so `CommonMark` renderers can
69/// present the enclosed tldr-pages Markdown without leaking extension syntax.
70/// It must be the first non-empty construct. The remaining source is parsed
71/// independently, so its first H1 remains document metadata rather than part
72/// of the preface.
73///
74/// # Errors
75///
76/// Returns [`MarkdownParseError`] for an unterminated preface or malformed
77/// embedded tldr page.
78pub 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
112/// Mask a leading BOM and terminal-unsafe control characters with spaces.
113///
114/// A BOM would hide the tldr opening marker and demote the first heading, while
115/// raw control characters would pass escape sequences through to terminals.
116/// Replacements keep every byte offset valid for source coordinates.
117fn 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
161/// Lower the ordinary document portion after extension extraction.
162fn 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
229/// Lower the Markdown event stream without imposing final document layout.
230fn 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
357/// A leading H1 names the document; it is metadata rather than manual content.
358fn 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            // Reserved selectors and bare tree paths would shadow this
426            // heading in excerpt selection; keep it addressable instead.
427            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        // Ambiguous human-facing keys resolve to the first section that
439        // claimed them, matching the bare slug this heading renders as its
440        // anchor. A later duplicate owns only its own disambiguated id.
441        self.targets
442            .entry(base.clone())
443            .or_insert_with(|| id.clone());
444        // Heading attributes are source-level link aliases. Preserve the
445        // original alias even when its final section ID had to move out of the
446        // selector namespace (`{#root}`, `{#2.1}`, or `{#2.1/o3}`).
447        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
583/// Recursion budget shared by nested block containers and inline spans.
584///
585/// Parsing recurses once per nesting level, so unbounded input depth would
586/// overflow the stack before any allocation limit applies. Subtrees beyond
587/// this depth are preserved as unsupported source text with a diagnostic.
588const 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    /// Reserve one nesting level; callers must pair with [`Self::ascend`].
600    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    /// Consume the remainder of a just-opened tag, including nested tags.
623    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}