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