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