1mod 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#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ParsedMarkdown {
51 pub document: Document,
53 pub tldr: Option<TldrDocument>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum MarkdownParseError {
60 TldrDirective(TldrDirectiveError),
62 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
77pub 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
138fn 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#[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 §ions,
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
268fn 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
396fn 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 format!("{base}-section")
468 } else {
469 base
470 };
471 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 self.targets
491 .entry(base.clone())
492 .or_insert_with(|| id.clone());
493 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
575const 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 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 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}