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