Skip to main content

mant_engine/output/markdown/
mod.rs

1//! Renders the native query contract as deterministic portable `CommonMark`.
2
3mod blocks;
4mod inline;
5
6use std::ops::Range;
7
8use mant_ir::{
9    Block, DefinitionCase, DefinitionRole, DocumentMeta, DocumentSource, LayoutHint, NodeId,
10    OutlinePath, Section, SourceFormat, SourceSpan, TldrCommandPart, TldrDocument, TldrOrigin,
11};
12use mant_protocol::{ExcerptSelection, OutlineNode, OutlineReference, QueryExcerpt, QueryOutline};
13
14pub(crate) use self::inline::{
15    code_span as commonmark_code_span, escape_text as escape_commonmark,
16};
17use self::{
18    blocks::{RenderedBlocks, render_blocks, render_blocks_with_entries},
19    inline::{code_span, escape_text},
20};
21use crate::{ResolvedContent, projection::DOCUMENT_ROOT_ID};
22
23/// Markdown serialization controls that do not alter the query IR.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct MarkdownOptions {
26    /// Emit stable raw-HTML destinations and links for document-local references.
27    pub preserve_anchors: bool,
28}
29
30impl MarkdownOptions {
31    /// Addressable Markdown used by consumers of `mant.markdown/v1`.
32    pub const ADDRESSABLE: Self = Self {
33        preserve_anchors: true,
34    };
35}
36
37/// Render a complete query as clean Markdown without a trailing newline.
38#[must_use]
39pub fn render_markdown(query: &ResolvedContent) -> String {
40    render_markdown_with_options(query, MarkdownOptions::default())
41}
42
43/// Render a complete query using explicit presentation-only options.
44#[must_use]
45pub fn render_markdown_with_options(query: &ResolvedContent, options: MarkdownOptions) -> String {
46    render_markdown_artifact(query, options).text
47}
48
49pub(crate) struct MarkdownArtifact {
50    pub(crate) text: String,
51    pub(crate) nodes: Vec<MarkdownNodeRange>,
52}
53
54#[derive(Clone)]
55pub(crate) struct MarkdownNodeRange {
56    pub(crate) range: Range<usize>,
57    pub(crate) node: MarkdownNode,
58}
59
60#[derive(Clone)]
61pub(crate) struct MarkdownSection {
62    pub(crate) path: OutlinePath,
63    pub(crate) id: NodeId,
64    pub(crate) title: String,
65    pub(crate) ancestors: Vec<OutlineReference>,
66}
67
68#[derive(Clone)]
69pub(crate) enum MarkdownNode {
70    Tldr,
71    DocumentRoot,
72    DocumentSection {
73        section: MarkdownSection,
74        source: Option<SourceSpan>,
75    },
76    DocumentEntry {
77        path: OutlinePath,
78        id: NodeId,
79        title: String,
80        role: DefinitionRole,
81        case: DefinitionCase,
82        names: Vec<String>,
83        section: Option<MarkdownSection>,
84        source: Option<SourceSpan>,
85    },
86}
87
88pub(crate) fn render_addressable_markdown(query: &ResolvedContent) -> MarkdownArtifact {
89    render_markdown_artifact(query, MarkdownOptions::ADDRESSABLE)
90}
91
92fn render_markdown_artifact(query: &ResolvedContent, options: MarkdownOptions) -> MarkdownArtifact {
93    let mut output = ArtifactBuilder::default();
94    output.push(&heading(
95        1,
96        document_title(
97            &query.label,
98            query.document.as_ref().map(|document| &document.source),
99            query.document.as_ref().map(|document| &document.meta),
100        ),
101    ));
102
103    if let Some(tldr) = &query.tldr {
104        for (index, block) in render_tldr(tldr).into_iter().enumerate() {
105            let range = output.push(&block);
106            if index == 0 {
107                output.begin_tldr(range.start);
108            }
109        }
110        if query.document.is_some() {
111            output.push("---");
112        }
113    }
114
115    if let Some(document) = &query.document {
116        if !document.blocks.is_empty() {
117            let start = if options.preserve_anchors {
118                output.push(&inline::html_anchor(DOCUMENT_ROOT_ID)).start
119            } else {
120                output.text.len()
121            };
122            output.begin_root(start);
123            let rendered = render_blocks_with_entries(&document.blocks, options);
124            output.push_scope(rendered, None, None);
125        }
126        render_artifact_sections(&mut output, &document.sections, &[], &[], 2, options);
127    }
128    output.finish()
129}
130
131#[derive(Default)]
132struct ArtifactBuilder {
133    text: String,
134    nodes: Vec<MarkdownNodeRange>,
135    tldr: Option<usize>,
136    root: Option<usize>,
137    last_section: Option<usize>,
138}
139
140impl ArtifactBuilder {
141    fn push(&mut self, block: &str) -> Range<usize> {
142        if block.is_empty() {
143            return self.text.len()..self.text.len();
144        }
145        if !self.text.is_empty() {
146            self.text.push_str("\n\n");
147        }
148        let start = self.text.len();
149        self.text.push_str(block);
150        start..self.text.len()
151    }
152
153    fn begin_tldr(&mut self, start: usize) {
154        self.tldr = Some(self.node(start, MarkdownNode::Tldr));
155    }
156
157    fn begin_root(&mut self, start: usize) {
158        self.close_tldr(start);
159        self.root = Some(self.node(start, MarkdownNode::DocumentRoot));
160    }
161
162    fn begin_section(
163        &mut self,
164        start: usize,
165        section: MarkdownSection,
166        source: Option<SourceSpan>,
167    ) {
168        self.close_tldr(start);
169        if let Some(root) = self.root.take() {
170            self.nodes[root].range.end = start;
171        }
172        if let Some(previous) = self.last_section {
173            self.nodes[previous].range.end = start;
174        }
175        self.last_section =
176            Some(self.node(start, MarkdownNode::DocumentSection { section, source }));
177    }
178
179    fn push_scope(
180        &mut self,
181        rendered: RenderedBlocks,
182        section: Option<&MarkdownSection>,
183        coordinates: Option<&[usize]>,
184    ) {
185        if rendered.text.is_empty() {
186            return;
187        }
188        let block = self.push(&rendered.text);
189        for entry in rendered.entries {
190            let path = OutlinePath::nested_entry(coordinates, &entry.indices)
191                .expect("enumerated entry paths are one-based");
192            self.nodes.push(MarkdownNodeRange {
193                range: block.start + entry.start..block.start + entry.end,
194                node: MarkdownNode::DocumentEntry {
195                    path,
196                    id: entry.identity.id,
197                    title: entry.identity.names.join(", "),
198                    role: entry.identity.role,
199                    case: entry.identity.case,
200                    names: entry.identity.names,
201                    section: section.cloned(),
202                    source: entry.source,
203                },
204            });
205        }
206    }
207
208    fn node(&mut self, start: usize, node: MarkdownNode) -> usize {
209        let index = self.nodes.len();
210        self.nodes.push(MarkdownNodeRange {
211            range: start..self.text.len(),
212            node,
213        });
214        index
215    }
216
217    fn close_tldr(&mut self, end: usize) {
218        if let Some(tldr) = self.tldr.take() {
219            self.nodes[tldr].range.end = end;
220        }
221    }
222
223    fn finish(mut self) -> MarkdownArtifact {
224        let end = self.text.trim_end().len();
225        self.text.truncate(end);
226        self.close_tldr(end);
227        if let Some(root) = self.root.take() {
228            self.nodes[root].range.end = end;
229        }
230        if let Some(section) = self.last_section {
231            self.nodes[section].range.end = end;
232        }
233        for node in &mut self.nodes {
234            node.range.end = node.range.end.min(end);
235        }
236        MarkdownArtifact {
237            text: self.text,
238            nodes: self.nodes,
239        }
240    }
241}
242
243fn render_artifact_sections(
244    output: &mut ArtifactBuilder,
245    sections: &[Section],
246    parent: &[usize],
247    ancestors: &[OutlineReference],
248    depth: usize,
249    options: MarkdownOptions,
250) {
251    for (index, section) in sections.iter().enumerate() {
252        let mut coordinates = parent.to_vec();
253        coordinates.push(index + 1);
254        let path =
255            OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
256        let rendered_heading = if options.preserve_anchors {
257            format!(
258                "{}\n\n{}",
259                inline::html_anchor(&section.id),
260                heading(depth, &section.title)
261            )
262        } else {
263            heading(depth, &section.title)
264        };
265        let range = output.push(&rendered_heading);
266        let reference = MarkdownSection {
267            path: path.clone(),
268            id: section.id.clone(),
269            title: section.title.clone(),
270            ancestors: ancestors.to_vec(),
271        };
272        output.begin_section(range.start, reference.clone(), section.source);
273        output.push_scope(
274            render_blocks_with_entries(&section.blocks, options),
275            Some(&reference),
276            Some(&coordinates),
277        );
278        let mut child_ancestors = ancestors.to_vec();
279        child_ancestors.push(OutlineReference {
280            path: path.to_string().into(),
281            id: section.id.clone(),
282            title: section.title.clone(),
283        });
284        render_artifact_sections(
285            output,
286            &section.children,
287            &coordinates,
288            &child_ancestors,
289            depth.saturating_add(1),
290            options,
291        );
292    }
293}
294
295/// Render a complete query outline as a nested `CommonMark` list.
296#[must_use]
297pub fn render_outline_markdown(outline: &QueryOutline) -> String {
298    let label = document_label(
299        document_title(
300            &outline.label,
301            outline.source.as_ref(),
302            outline.meta.as_ref(),
303        ),
304        outline
305            .meta
306            .as_ref()
307            .and_then(|meta| meta.manual_section.as_deref()),
308    );
309    let mut blocks = vec![heading(1, &format!("{label} outline"))];
310    if let Some(message) = super::outline_empty_message(outline) {
311        blocks.push(message);
312    } else if !outline.nodes.is_empty() {
313        blocks.push(outline_list(&outline.nodes, 0));
314    }
315    blocks.join("\n\n").trim_end().to_owned()
316}
317
318/// Render selected query nodes with their outline context.
319#[must_use]
320pub fn render_excerpt_markdown(excerpt: &QueryExcerpt) -> String {
321    render_excerpt_markdown_with_options(excerpt, MarkdownOptions::default())
322}
323
324/// Render selected nodes using explicit presentation-only options.
325#[must_use]
326pub fn render_excerpt_markdown_with_options(
327    excerpt: &QueryExcerpt,
328    options: MarkdownOptions,
329) -> String {
330    let label = document_label(
331        document_title(
332            &excerpt.label,
333            excerpt.source.as_ref(),
334            excerpt.meta.as_ref(),
335        ),
336        excerpt
337            .meta
338            .as_ref()
339            .and_then(|meta| meta.manual_section.as_deref()),
340    );
341    let mut output = vec![heading(1, &label)];
342    for (index, selection) in excerpt.selections.iter().enumerate() {
343        if index > 0 {
344            output.push("---".to_owned());
345        }
346        output.push(selection_context(selection));
347        match selection {
348            ExcerptSelection::Tldr { document, .. } => output.extend(render_tldr(document)),
349            ExcerptSelection::DocumentRoot { blocks, .. } => {
350                output.extend(render_blocks(blocks, options));
351            }
352            ExcerptSelection::DocumentSection { section, .. } => {
353                render_sections(&mut output, std::slice::from_ref(section), 2, options);
354            }
355            ExcerptSelection::DocumentEntry { entry, .. } => {
356                output.extend(render_blocks(
357                    &[Block::DefinitionList {
358                        items: vec![entry.clone()],
359                        compact: true,
360                        layout: LayoutHint::default(),
361                        source: None,
362                    }],
363                    options,
364                ));
365            }
366        }
367    }
368    output
369        .into_iter()
370        .filter(|block| !block.is_empty())
371        .collect::<Vec<_>>()
372        .join("\n\n")
373        .trim_end()
374        .to_owned()
375}
376
377fn outline_list(nodes: &[OutlineNode], depth: usize) -> String {
378    let mut lines = Vec::new();
379    for node in nodes {
380        lines.push(format!(
381            "{}- {} ({}) {}",
382            "  ".repeat(depth),
383            code_span(node.path()),
384            code_span(node.id()),
385            escape_text(node.title())
386        ));
387        let children = outline_list(node.children(), depth + 1);
388        if !children.is_empty() {
389            lines.push(children);
390        }
391    }
392    lines.join("\n")
393}
394
395fn selection_context(selection: &ExcerptSelection) -> String {
396    let trail = selection.outline();
397    let breadcrumb = trail
398        .ancestors
399        .iter()
400        .map(|ancestor| escape_text(&ancestor.title))
401        .chain(std::iter::once(escape_text(trail.title())))
402        .collect::<Vec<_>>()
403        .join(" → ");
404    format!("*Outline {}: {breadcrumb}*", code_span(trail.path()))
405}
406
407fn render_sections(
408    output: &mut Vec<String>,
409    sections: &[Section],
410    depth: usize,
411    options: MarkdownOptions,
412) {
413    for section in sections {
414        if options.preserve_anchors {
415            output.push(format!(
416                "{}\n\n{}",
417                inline::html_anchor(&section.id),
418                heading(depth, &section.title)
419            ));
420        } else {
421            output.push(heading(depth, &section.title));
422        }
423        output.extend(render_blocks(&section.blocks, options));
424        render_sections(output, &section.children, depth.saturating_add(1), options);
425    }
426}
427
428fn render_tldr(page: &TldrDocument) -> Vec<String> {
429    let mut output = vec![heading(2, "TLDR")];
430    output.extend(
431        page.description
432            .iter()
433            .filter(|line| !line.trim().is_empty())
434            .map(|line| escape_text(line.trim())),
435    );
436
437    if let Some(value) = page.more_information.as_deref() {
438        output.push(render_more_information(value));
439    }
440    if !page.examples.is_empty() {
441        output.push(heading(3, "Examples"));
442        for example in &page.examples {
443            if !example.description.trim().is_empty() {
444                output.push(format!("**{}**", escape_text(example.description.trim())));
445            }
446            if !example.command.is_empty() {
447                let resolved = example
448                    .command_parts
449                    .iter()
450                    .map(|part| match part {
451                        TldrCommandPart::Text { value }
452                        | TldrCommandPart::Placeholder { value } => value.as_str(),
453                    })
454                    .collect::<String>();
455                output.push(inline::fenced_code(
456                    if resolved.is_empty() {
457                        &example.command
458                    } else {
459                        &resolved
460                    },
461                    Some("sh"),
462                ));
463            }
464        }
465    }
466    if page.origin == TldrOrigin::TldrPages {
467        output.push(format!(
468            "*tldr-pages · CC BY 4.0 · {} · {}*",
469            escape_text(&page.platform),
470            escape_text(&page.language)
471        ));
472    }
473    output
474}
475
476fn render_more_information(value: &str) -> String {
477    let value = value.trim();
478    if value.starts_with("http://") || value.starts_with("https://") {
479        let (url, punctuation) = value
480            .strip_suffix('.')
481            .map_or((value, ""), |url| (url, "."));
482        if !url.chars().any(char::is_whitespace) && !url.contains(['<', '>']) {
483            return format!("**More information:** <{url}>{punctuation}");
484        }
485    }
486    format!("**More information:** {}", escape_text(value))
487}
488
489fn heading(depth: usize, title: &str) -> String {
490    format!("{} {}", "#".repeat(depth.clamp(1, 6)), escape_text(title))
491}
492
493fn document_title<'a>(
494    label: &'a str,
495    source: Option<&DocumentSource>,
496    meta: Option<&'a DocumentMeta>,
497) -> &'a str {
498    if source.is_some_and(|source| source.format == SourceFormat::Markdown) {
499        meta.and_then(|meta| meta.title.as_deref())
500            .filter(|title| !title.trim().is_empty())
501            .unwrap_or(label)
502    } else {
503        label
504    }
505}
506
507fn document_label(title: &str, section: Option<&str>) -> String {
508    section.map_or_else(|| title.to_owned(), |section| format!("{title}({section})"))
509}
510
511#[cfg(test)]
512mod tests;