Skip to main content

mant_engine/markdown/
mod.rs

1//! Parses a conservative Markdown subset into the shared document contract.
2//!
3//! Supported syntax becomes semantic IR 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_ir::{
26    Block, Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, Inline, ParserInfo,
27    Section, SourceFormat, TldrDocument, TldrOrigin, validate_document,
28    visit::{self, VisitMut},
29};
30use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
31
32use self::{
33    blocks::parse_block,
34    container::split_markdown,
35    inline::{inline_text, parse_inlines},
36    layout::normalize_markdown_layout,
37    options::{extract_entry_directives, normalize_entry_lists},
38    source::MarkdownSource,
39};
40use crate::text_safety::mask_terminal_controls;
41use crate::{
42    projection::DOCUMENT_ROOT_ID,
43    tldr::{TldrPageLocation, TldrParseError, parse_tldr_page},
44};
45
46type SpannedEvent<'a> = (Event<'a>, Range<usize>);
47
48/// Complete result of parsing one `ManT`-flavoured Markdown input.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ParsedMarkdown {
51    /// Authoritative normalized Markdown document.
52    pub document: Document,
53    /// Optional document-owned quick reference.
54    pub tldr: Option<TldrDocument>,
55}
56
57/// Invalid structure in `ManT`'s optional top-level Markdown extension.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum MarkdownParseError {
60    /// The top-level `ManT` tldr container is malformed.
61    TldrDirective(TldrDirectiveError),
62    /// Embedded tldr Markdown is structurally invalid.
63    TldrPage(TldrParseError),
64}
65
66impl fmt::Display for MarkdownParseError {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::TldrDirective(error) => error.fmt(formatter),
70            Self::TldrPage(error) => write!(formatter, "invalid embedded tldr page: {error}"),
71        }
72    }
73}
74
75impl Error for MarkdownParseError {}
76
77/// Split `ManT`'s optional leading tldr preface from the Markdown document.
78///
79/// Invisible HTML comments delimit the preface so `CommonMark` renderers can
80/// present the enclosed tldr-pages Markdown without leaking extension syntax.
81/// It must be the first non-empty construct. The remaining source is parsed
82/// independently, so its first H1 remains document metadata rather than part
83/// of the preface.
84///
85/// # Errors
86///
87/// Returns [`MarkdownParseError`] for an unterminated preface or malformed
88/// embedded tldr page.
89pub fn parse_markdown(
90    source_text: &str,
91    source_path: Option<String>,
92) -> Result<ParsedMarkdown, MarkdownParseError> {
93    let mut sanitize_diagnostics = Vec::new();
94    let sanitized = sanitize_source(source_text, &mut sanitize_diagnostics);
95    let source_text = sanitized.as_deref().unwrap_or(source_text);
96    let parts = split_markdown(source_text).map_err(MarkdownParseError::TldrDirective)?;
97    let tldr = parts
98        .tldr
99        .map(|source| {
100            parse_tldr_page(
101                source,
102                TldrPageLocation {
103                    platform: "embedded".to_owned(),
104                    language: "und".to_owned(),
105                    source_path: source_path.clone().unwrap_or_else(|| "<stdin>".to_owned()),
106                },
107            )
108            .map(|mut page| {
109                page.origin = TldrOrigin::Embedded;
110                page
111            })
112            .map_err(MarkdownParseError::TldrPage)
113        })
114        .transpose()?;
115    let mut entry_diagnostics = Vec::new();
116    let (masked_document, declarations) =
117        extract_entry_directives(parts.document.as_ref(), &mut entry_diagnostics);
118    let document_source = masked_document
119        .as_deref()
120        .unwrap_or_else(|| parts.document.as_ref());
121    let mut document = parse_document_with_entries(
122        document_source,
123        source_path,
124        declarations,
125        &mut entry_diagnostics,
126    );
127    if !entry_diagnostics.is_empty() {
128        entry_diagnostics.extend(std::mem::take(&mut document.diagnostics));
129        document.diagnostics = entry_diagnostics;
130    }
131    if !sanitize_diagnostics.is_empty() {
132        sanitize_diagnostics.extend(std::mem::take(&mut document.diagnostics));
133        document.diagnostics = sanitize_diagnostics;
134    }
135    Ok(ParsedMarkdown { document, tldr })
136}
137
138/// Mask a leading BOM and terminal-unsafe control characters with spaces.
139///
140/// A BOM would hide the tldr opening marker and demote the first heading, while
141/// raw control characters would pass escape sequences through to terminals.
142/// Replacements keep every byte offset valid for source coordinates.
143fn sanitize_source(source_text: &str, diagnostics: &mut Vec<Diagnostic>) -> Option<String> {
144    let bom = source_text.starts_with('\u{feff}');
145    let rest = if bom {
146        &source_text['\u{feff}'.len_utf8()..]
147    } else {
148        source_text
149    };
150    let (masked, controls) = mask_terminal_controls(rest);
151    if !bom && masked.is_none() {
152        return None;
153    }
154
155    let mut sanitized = String::with_capacity(source_text.len());
156    if bom {
157        sanitized.push_str("   ");
158    }
159    sanitized.push_str(masked.as_deref().unwrap_or(rest));
160
161    if bom {
162        diagnostics.push(Diagnostic {
163            level: DiagnosticLevel::Warning,
164            code: Some("markdown.byte-order-mark".to_owned()),
165            message: "masked a leading byte-order mark".to_owned(),
166            source: None,
167        });
168    }
169    if controls > 0 {
170        diagnostics.push(Diagnostic {
171            level: DiagnosticLevel::Warning,
172            code: Some("markdown.control-characters".to_owned()),
173            message: format!("masked {controls} terminal-unsafe control character(s)"),
174            source: None,
175        });
176    }
177    Some(sanitized)
178}
179
180/// Lower the ordinary document portion after extension extraction.
181#[cfg(test)]
182fn parse_document(source_text: &str, source_path: Option<String>) -> Document {
183    let mut diagnostics = Vec::new();
184    parse_document_with_entries(source_text, source_path, BTreeMap::new(), &mut diagnostics)
185}
186
187fn parse_document_with_entries(
188    source_text: &str,
189    source_path: Option<String>,
190    mut declarations: BTreeMap<u32, options::EntryDeclaration>,
191    entry_diagnostics: &mut Vec<Diagnostic>,
192) -> Document {
193    let source = MarkdownSource::new(source_text);
194    let ParsedDocumentStructure {
195        diagnostics,
196        mut root_blocks,
197        flat_sections,
198        mut ids,
199        title,
200        document_title_id,
201    } = lower_document_structure(source_text, &source);
202    let mut sections = nest_sections(flat_sections);
203    let extracted_title = extract_document_title(
204        &mut root_blocks,
205        &mut sections,
206        document_title_id.as_deref(),
207    );
208    if extracted_title {
209        let replacement = if root_blocks.is_empty() {
210            sections.first().map(|section| section.id.as_str())
211        } else {
212            Some(DOCUMENT_ROOT_ID)
213        };
214        ids.remap_target(document_title_id.as_deref(), replacement);
215    }
216    normalize_markdown_layout(&source, &mut root_blocks, &mut sections);
217    normalize_entry_lists(&mut root_blocks, &mut declarations, entry_diagnostics);
218    normalize_section_entries(&mut sections, &mut declarations, entry_diagnostics);
219    for declaration in declarations.into_values() {
220        entry_diagnostics.push(Diagnostic {
221            level: DiagnosticLevel::Warning,
222            code: Some("markdown.semantic-entry-list".to_owned()),
223            message: "semantic-entry directive did not resolve to a Markdown bullet list"
224                .to_owned(),
225            source: Some(declaration.source),
226        });
227    }
228    let retained_targets = crate::definitions::identify_definitions(
229        &mut root_blocks,
230        &mut sections,
231        &ids.targets.keys().cloned().collect(),
232    );
233    for target in retained_targets {
234        ids.targets.insert(target.clone(), target);
235    }
236    entry_diagnostics.extend(crate::projection::semantic_selector_diagnostics(
237        &root_blocks,
238        &sections,
239    ));
240    let mut document = Document {
241        parser: Some(markdown_parser()),
242        source: DocumentSource {
243            format: SourceFormat::Markdown,
244            path: source_path,
245        },
246        meta: DocumentMeta {
247            title,
248            ..DocumentMeta::default()
249        },
250        diagnostics,
251        blocks: root_blocks,
252        sections,
253    };
254    LocalLinkResolver::new(&ids.targets).visit_document_mut(&mut document);
255    document.diagnostics.extend(validate_document(&document));
256    document
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: id.into(),
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_parser() -> ParserInfo {
352    ParserInfo {
353        name: "pulldown-cmark".to_owned(),
354        version: "0.13".to_owned(),
355    }
356}
357
358fn normalize_section_entries(
359    sections: &mut [Section],
360    declarations: &mut BTreeMap<u32, options::EntryDeclaration>,
361    diagnostics: &mut Vec<Diagnostic>,
362) {
363    for section in sections {
364        normalize_entry_lists(&mut section.blocks, declarations, diagnostics);
365        normalize_section_entries(&mut section.children, declarations, diagnostics);
366    }
367}
368
369fn markdown_options() -> Options {
370    Options::ENABLE_TABLES
371        | Options::ENABLE_FOOTNOTES
372        | Options::ENABLE_STRIKETHROUGH
373        | Options::ENABLE_TASKLISTS
374        | Options::ENABLE_HEADING_ATTRIBUTES
375        | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
376        | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
377        | Options::ENABLE_MATH
378        | Options::ENABLE_GFM
379        | Options::ENABLE_DEFINITION_LIST
380        | Options::ENABLE_SUPERSCRIPT
381        | Options::ENABLE_SUBSCRIPT
382        | Options::ENABLE_WIKILINKS
383}
384
385fn heading_level(level: HeadingLevel) -> u8 {
386    match level {
387        HeadingLevel::H1 => 1,
388        HeadingLevel::H2 => 2,
389        HeadingLevel::H3 => 3,
390        HeadingLevel::H4 => 4,
391        HeadingLevel::H5 => 5,
392        HeadingLevel::H6 => 6,
393    }
394}
395
396/// A leading H1 names the document; it is metadata rather than manual content.
397fn extract_document_title(
398    root_blocks: &mut Vec<Block>,
399    sections: &mut Vec<Section>,
400    document_title_id: Option<&str>,
401) -> bool {
402    let Some(document_title_id) = document_title_id else {
403        return false;
404    };
405    if sections.first().map(|section| section.id.as_str()) != Some(document_title_id) {
406        return false;
407    }
408    let title = sections.remove(0);
409    root_blocks.extend(title.blocks);
410    sections.splice(0..0, title.children);
411    true
412}
413
414struct FlatSection {
415    level: u8,
416    is_document_title: bool,
417    section: Section,
418}
419
420fn nest_sections(flat: Vec<FlatSection>) -> Vec<Section> {
421    let mut roots = Vec::new();
422    let mut stack: Vec<FlatSection> = Vec::new();
423
424    for next in flat {
425        while stack
426            .last()
427            .is_some_and(|current| current.is_document_title || current.level >= next.level)
428        {
429            attach_completed(&mut stack, &mut roots);
430        }
431        stack.push(next);
432    }
433    while !stack.is_empty() {
434        attach_completed(&mut stack, &mut roots);
435    }
436    roots
437}
438
439fn attach_completed(stack: &mut Vec<FlatSection>, roots: &mut Vec<Section>) {
440    let completed = stack.pop().expect("caller checks non-empty stack").section;
441    if let Some(parent) = stack.last_mut() {
442        parent.section.children.push(completed);
443    } else {
444        roots.push(completed);
445    }
446}
447
448#[derive(Default)]
449struct SectionIds {
450    counts: HashMap<String, usize>,
451    assigned: HashSet<String>,
452    targets: HashMap<String, String>,
453}
454
455impl SectionIds {
456    fn allocate(&mut self, title: &str, explicit: Option<&str>) -> String {
457        let explicit = explicit
458            .map(str::trim)
459            .filter(|value| !value.is_empty())
460            .map(ToOwned::to_owned);
461        let base = explicit.clone().unwrap_or_else(|| slug(title));
462        let base = if base.is_empty() {
463            "section".to_owned()
464        } else if crate::projection::is_reserved_selector(&base) {
465            // Reserved selectors and bare tree paths would shadow this
466            // heading in excerpt selection; keep it addressable instead.
467            format!("{base}-section")
468        } else {
469            base
470        };
471        // Disambiguate on the final id, not the per-base count: `# Foo 2`
472        // slugs to base `foo-2`, which collides with the `foo-2` a second
473        // `# Foo` produces. Counting per base alone would hand both the same
474        // id, silently misattributing search ownership between them.
475        let count = self.counts.entry(base.clone()).or_default();
476        let id = loop {
477            *count += 1;
478            let candidate = if *count == 1 {
479                base.clone()
480            } else {
481                format!("{base}-{}", *count)
482            };
483            if self.assigned.insert(candidate.clone()) {
484                break candidate;
485            }
486        };
487        // Ambiguous human-facing keys resolve to the first section that
488        // claimed them, matching the bare slug this heading renders as its
489        // anchor. A later duplicate owns only its own disambiguated id.
490        self.targets
491            .entry(base.clone())
492            .or_insert_with(|| id.clone());
493        // Heading attributes are source-level link aliases. Preserve the
494        // original alias even when its final section ID had to move out of the
495        // selector namespace (`{#root}`, `{#2.1}`, or `{#2.1/e3}`).
496        if let Some(explicit) = explicit {
497            self.targets.entry(explicit).or_insert_with(|| id.clone());
498        }
499        self.targets
500            .entry(slug(title))
501            .or_insert_with(|| id.clone());
502        self.targets.insert(id.clone(), id.clone());
503        id
504    }
505
506    fn remap_target(&mut self, current: Option<&str>, replacement: Option<&str>) {
507        let Some(current) = current else {
508            return;
509        };
510        if let Some(replacement) = replacement {
511            for target in self.targets.values_mut() {
512                if target == current {
513                    replacement.clone_into(target);
514                }
515            }
516        } else {
517            self.targets.retain(|_, target| target != current);
518        }
519    }
520}
521
522fn slug(value: &str) -> String {
523    let mut output = String::new();
524    let mut separator = false;
525    for character in value.chars().flat_map(char::to_lowercase) {
526        if character.is_alphanumeric() || character == '_' {
527            if separator && !output.is_empty() {
528                output.push('-');
529            }
530            separator = false;
531            output.push(character);
532        } else {
533            separator = true;
534        }
535    }
536    output.trim_matches('-').to_owned()
537}
538
539struct LocalLinkResolver<'targets> {
540    targets: &'targets HashMap<String, String>,
541}
542
543impl<'targets> LocalLinkResolver<'targets> {
544    fn new(targets: &'targets HashMap<String, String>) -> Self {
545        Self { targets }
546    }
547}
548
549impl VisitMut for LocalLinkResolver<'_> {
550    fn visit_inline_mut(&mut self, inline: &mut Inline) {
551        if let Inline::Link {
552            target: mant_ir::LinkTarget::Section { id },
553            ..
554        } = inline
555        {
556            let lookup = id.trim().trim_start_matches('#');
557            if let Some(resolved) = self
558                .targets
559                .get(lookup)
560                .or_else(|| self.targets.get(&slug(lookup)))
561            {
562                *id = resolved.as_str().into();
563            }
564        }
565        visit::walk_inline_mut(self, inline);
566    }
567}
568
569pub(super) struct EventCursor<'a> {
570    events: Vec<SpannedEvent<'a>>,
571    position: usize,
572    depth: usize,
573}
574
575/// Recursion budget shared by nested block containers and inline spans.
576///
577/// Parsing recurses once per nesting level, so unbounded input depth would
578/// overflow the stack before any allocation limit applies. Subtrees beyond
579/// this depth are preserved as unsupported source text with a diagnostic.
580const MAX_NESTING_DEPTH: usize = 64;
581
582impl<'a> EventCursor<'a> {
583    fn new(events: Vec<SpannedEvent<'a>>) -> Self {
584        Self {
585            events,
586            position: 0,
587            depth: 0,
588        }
589    }
590
591    /// Reserve one nesting level; callers must pair with [`Self::ascend`].
592    pub(super) fn try_descend(&mut self) -> bool {
593        if self.depth >= MAX_NESTING_DEPTH {
594            return false;
595        }
596        self.depth += 1;
597        true
598    }
599
600    pub(super) fn ascend(&mut self) {
601        self.depth = self.depth.saturating_sub(1);
602    }
603
604    pub(super) fn peek(&self) -> Option<&SpannedEvent<'a>> {
605        self.events.get(self.position)
606    }
607
608    pub(super) fn next(&mut self) -> Option<SpannedEvent<'a>> {
609        let event = self.events.get(self.position)?.clone();
610        self.position += 1;
611        Some(event)
612    }
613
614    /// Consume the remainder of a just-opened tag, including nested tags.
615    pub(super) fn consume_balanced(&mut self, start: Range<usize>) -> Range<usize> {
616        let mut depth = 1usize;
617        let mut end = start.end;
618        while let Some((event, range)) = self.next() {
619            end = range.end;
620            match event {
621                Event::Start(_) => depth = depth.saturating_add(1),
622                Event::End(_) => {
623                    depth = depth.saturating_sub(1);
624                    if depth == 0 {
625                        break;
626                    }
627                }
628                _ => {}
629            }
630        }
631        start.start..end
632    }
633
634    pub(super) fn subtree_contains_task_marker(&self) -> bool {
635        let mut depth = 1usize;
636        for (event, _) in &self.events[self.position..] {
637            match event {
638                Event::TaskListMarker(_) => return true,
639                Event::Start(_) => depth = depth.saturating_add(1),
640                Event::End(_) => {
641                    depth = depth.saturating_sub(1);
642                    if depth == 0 {
643                        return false;
644                    }
645                }
646                _ => {}
647            }
648        }
649        false
650    }
651}