Skip to main content

brief/emit/
llm.rs

1use crate::ast::{Block, CodeAttrs, Document, Inline, Row, ShortArgs};
2use crate::minify::{self, MinifyOptions, MinifyWarning};
3use crate::shortcode::Registry;
4use std::fmt::Write;
5
6/// A minified code block longer than this many source lines triggers a B0702
7/// warning that the LLM consumer cannot reference original line numbers.
8const LONG_BLOCK_LINE_THRESHOLD: usize = 50;
9
10#[derive(Clone, Debug)]
11pub struct Opts {
12    pub strip_emphasis: bool,
13    pub keep_table_rule: bool,
14    pub keep_asset_urls: bool,
15    pub keep_metadata: bool,
16    /// Master toggle for code-block minification. When false, every code
17    /// block is emitted verbatim regardless of tag or `@minify`.
18    pub minify_code_blocks: bool,
19    /// Lowercased language tags eligible for the configured minifiers.
20    pub minify_languages: Vec<String>,
21    /// Keep the surrounding ```lang fence around minified output. When
22    /// false, only the minified body is emitted (no fence).
23    pub preserve_code_fences: bool,
24}
25
26impl Default for Opts {
27    fn default() -> Self {
28        // The Opts default is used in unit tests and in fall-back code paths
29        // when no `brief.toml` is on disk. Keeping it in sync with the
30        // canonical default in `config::default_minify_languages` matters
31        // because the LLM emit pass is the one that actually consults this
32        // list to decide whether to attempt minification on a tag the
33        // dispatcher knows about.
34        Opts {
35            strip_emphasis: false,
36            keep_table_rule: false,
37            keep_asset_urls: false,
38            keep_metadata: false,
39            minify_code_blocks: true,
40            minify_languages: vec![
41                "json".into(),
42                "jsonl".into(),
43                "rust".into(),
44                "rs".into(),
45                "c".into(),
46                "h".into(),
47                "cpp".into(),
48                "c++".into(),
49                "cc".into(),
50                "cxx".into(),
51                "hpp".into(),
52                "hxx".into(),
53                "java".into(),
54                "go".into(),
55                "javascript".into(),
56                "js".into(),
57                "typescript".into(),
58                "ts".into(),
59                "sql".into(),
60            ],
61            preserve_code_fences: true,
62        }
63    }
64}
65
66pub fn render(doc: &Document, reg: &Registry, opts: &Opts) -> (String, Vec<String>) {
67    let footnotes = collect_footnotes(doc);
68    let mut out = String::new();
69    let frontmatter_minify_code = doc
70        .metadata
71        .as_ref()
72        .and_then(|m| m.get("minify_code"))
73        .and_then(|v| v.as_bool());
74    if opts.keep_metadata {
75        if let Some(meta) = &doc.metadata {
76            // Re-serialize the metadata table to preserve it as Brief
77            // frontmatter at the top of the LLM output.
78            let body = toml::to_string(meta).unwrap_or_default();
79            out.push_str("+++\n");
80            out.push_str(&body);
81            if !body.ends_with('\n') {
82                out.push('\n');
83            }
84            out.push_str("+++\n\n");
85        }
86    }
87    let mut ctx = Ctx {
88        reg,
89        opts,
90        counter: 0,
91        in_footnote: false,
92        warnings: Vec::new(),
93        frontmatter_minify_code,
94        resolved_refs: &doc.resolved_refs,
95    };
96    for b in &doc.blocks {
97        render_block(b, &mut ctx, &mut out, 0);
98    }
99    if !footnotes.is_empty() {
100        emit_footnotes_section(&footnotes, reg, opts, &doc.resolved_refs, &mut out);
101    }
102    let warnings = ctx.warnings;
103    let mut collapsed = String::with_capacity(out.len());
104    let mut nl_run = 0;
105    for c in out.chars() {
106        if c == '\n' {
107            nl_run += 1;
108            if nl_run <= 2 {
109                collapsed.push(c);
110            }
111        } else {
112            nl_run = 0;
113            collapsed.push(c);
114        }
115    }
116    (collapsed, warnings)
117}
118
119struct Ctx<'a> {
120    reg: &'a Registry,
121    opts: &'a Opts,
122    counter: u32,
123    in_footnote: bool,
124    warnings: Vec<String>,
125    frontmatter_minify_code: Option<bool>,
126    resolved_refs: &'a std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
127}
128
129fn render_block(b: &Block, ctx: &mut Ctx, out: &mut String, indent: usize) {
130    let pad: String = std::iter::repeat(' ').take(indent).collect();
131    match b {
132        Block::Heading { level, content, .. } => {
133            let hashes: String = std::iter::repeat('#').take(*level as usize).collect();
134            let _ = write!(out, "{} ", hashes);
135            render_inline_seq(content, ctx, out);
136            out.push('\n');
137        }
138        Block::Paragraph { content, .. } => {
139            if content.is_empty() {
140                return;
141            }
142            out.push_str(&pad);
143            render_inline_seq(content, ctx, out);
144            out.push('\n');
145        }
146        Block::List { ordered, items, .. } => {
147            use crate::ast::TaskState;
148            for (i, it) in items.iter().enumerate() {
149                let marker = if *ordered {
150                    format!("{}.", i + 1)
151                } else {
152                    "-".to_string()
153                };
154                let _ = write!(out, "{}{} ", pad, marker);
155                if let Some(state) = it.task {
156                    out.push_str(match state {
157                        TaskState::Done => "[x] ",
158                        TaskState::Todo => "[ ] ",
159                    });
160                }
161                render_inline_seq(&it.content, ctx, out);
162                out.push('\n');
163                for c in &it.children {
164                    render_block(c, ctx, out, indent + 1);
165                }
166            }
167        }
168        Block::Blockquote { children, .. } => {
169            for c in children {
170                let mut s = String::new();
171                render_block(c, ctx, &mut s, 0);
172                for line in s.lines() {
173                    out.push_str("> ");
174                    out.push_str(line);
175                    out.push('\n');
176                }
177            }
178        }
179        Block::CodeBlock {
180            lang, body, attrs, ..
181        } => {
182            emit_code_block(lang.as_deref(), body, attrs, ctx, out);
183        }
184        Block::Table { header, rows, .. } => render_table(header, rows, ctx, out),
185        Block::DefinitionList { items, .. } => {
186            out.push_str("@dl\n");
187            for it in items {
188                render_inline_seq(&it.term, ctx, out);
189                out.push('\n');
190                out.push_str(": ");
191                render_inline_seq(&it.definition, ctx, out);
192                out.push('\n');
193            }
194            out.push_str("@end\n");
195        }
196        Block::HorizontalRule { .. } => out.push_str("---\n"),
197        Block::BlockShortcode {
198            name,
199            args,
200            children,
201            ..
202        } => {
203            render_block_shortcode_llm(name, args, children, ctx, out, indent);
204        }
205    }
206}
207
208fn emit_code_block(
209    lang: Option<&str>,
210    body: &str,
211    attrs: &CodeAttrs,
212    ctx: &mut Ctx,
213    out: &mut String,
214) {
215    let minified = try_minify(lang, body, attrs, ctx);
216    let body_to_emit = minified.as_deref().unwrap_or(body);
217    if ctx.opts.preserve_code_fences {
218        out.push_str("```");
219        if let Some(l) = lang {
220            out.push_str(l);
221        }
222        out.push('\n');
223        out.push_str(body_to_emit);
224        out.push('\n');
225        out.push_str("```\n");
226    } else {
227        out.push_str(body_to_emit);
228        out.push('\n');
229    }
230}
231
232fn try_minify(lang: Option<&str>, body: &str, attrs: &CodeAttrs, ctx: &mut Ctx) -> Option<String> {
233    if attrs.nominify {
234        return None;
235    }
236    let lang = lang?;
237    let lang_lc = lang.to_ascii_lowercase();
238
239    let force_minify = attrs.minify || attrs.keep_comments;
240
241    // Refusal gate (B0704). Only emitted when the user is trying to
242    // minify the block — either via an explicit attribute, or because the
243    // language is on the allowlist (i.e., it was deliberately enabled in
244    // brief.toml). We never error on a bare ```python``` block that was
245    // never opted in.
246    if let Some(reason) = minify::refusal_reason(&lang_lc) {
247        let in_allowlist = ctx
248            .opts
249            .minify_languages
250            .iter()
251            .any(|x| x.eq_ignore_ascii_case(&lang_lc));
252        if force_minify || in_allowlist {
253            ctx.warnings.push(format!(
254                "error[B0704]: language `{}` cannot be minified — {}",
255                lang, reason
256            ));
257        }
258        return None;
259    }
260
261    // Three layers of opt-out, in priority order: per-block @minify (or
262    // @minify-keep-comments) forces minification, overriding all opt-outs
263    // except @nominify; then frontmatter `minify_code = false`; then
264    // config `minify_code_blocks`.
265    if !force_minify {
266        if let Some(false) = ctx.frontmatter_minify_code {
267            return None;
268        }
269        if !ctx.opts.minify_code_blocks {
270            return None;
271        }
272    }
273
274    // Allowlist gate. Even with @minify, only languages with registered
275    // minifiers are processed; everything else falls back to verbatim.
276    let in_allowlist = ctx
277        .opts
278        .minify_languages
279        .iter()
280        .any(|x| x.eq_ignore_ascii_case(&lang_lc));
281    if !in_allowlist && !force_minify {
282        return None;
283    }
284    if !minify::is_supported(&lang_lc) {
285        return None;
286    }
287
288    let mopts = MinifyOptions {
289        keep_comments: attrs.keep_comments,
290    };
291    match minify::minify(&lang_lc, body, &mopts) {
292        Ok(out) => {
293            for w in &out.warnings {
294                match w {
295                    MinifyWarning::LineCommentConverted => {
296                        ctx.warnings.push(format!(
297                            "warning[B0703]: line comment converted to block form for minification in `{}` block; verify no `*/` content",
298                            lang
299                        ));
300                    }
301                }
302            }
303            let n_lines = body.lines().count();
304            if n_lines > LONG_BLOCK_LINE_THRESHOLD {
305                ctx.warnings.push(format!(
306                    "warning[B0702]: minified code block was originally {} lines. LLM consumers cannot reference specific lines after minification. Consider @nominify if line references matter.",
307                    n_lines
308                ));
309            }
310            Some(out.body)
311        }
312        Err(e) => {
313            ctx.warnings.push(format!(
314                "warning[B0701]: code block tagged `{}` did not parse; emitted verbatim ({})",
315                lang, e.message
316            ));
317            None
318        }
319    }
320}
321
322fn render_table(header: &Row, rows: &[Row], ctx: &mut Ctx, out: &mut String) {
323    let mut row_strs: Vec<Vec<String>> = Vec::new();
324    let mut h: Vec<String> = Vec::new();
325    for c in &header.cells {
326        let mut s = String::new();
327        render_inline_seq_to(c, ctx, &mut s);
328        h.push(s);
329    }
330    row_strs.push(h);
331    for r in rows {
332        let mut row: Vec<String> = Vec::new();
333        for c in &r.cells {
334            let mut s = String::new();
335            render_inline_seq_to(c, ctx, &mut s);
336            row.push(s);
337        }
338        row_strs.push(row);
339    }
340    // The parser flags table-column mismatch as a diagnostic, but the
341    // renderer must still be panic-free on any AST it's handed. Size column
342    // widths to the widest row, not the header.
343    let cols = row_strs.iter().map(|r| r.len()).max().unwrap_or(0);
344    let widths: Vec<usize> = (0..cols)
345        .map(|c| {
346            row_strs
347                .iter()
348                .map(|r| r.get(c).map(|s| s.chars().count()).unwrap_or(0))
349                .max()
350                .unwrap_or(0)
351        })
352        .collect();
353    for (i, row) in row_strs.iter().enumerate() {
354        out.push('|');
355        for (c, cell) in row.iter().enumerate() {
356            let w = widths.get(c).copied().unwrap_or(0);
357            let _ = write!(out, " {:width$} |", cell, width = w);
358        }
359        out.push('\n');
360        if i == 0 && ctx.opts.keep_table_rule {
361            out.push('|');
362            for w in &widths {
363                let dashes: String = std::iter::repeat('-').take(*w + 2).collect();
364                out.push_str(&dashes);
365                out.push('|');
366            }
367            out.push('\n');
368        }
369    }
370}
371
372fn render_block_shortcode_llm(
373    name: &str,
374    args: &ShortArgs,
375    children: &[Block],
376    ctx: &mut Ctx,
377    out: &mut String,
378    indent: usize,
379) {
380    if let Some(sc) = ctx.reg.get(name) {
381        if let Some(t) = &sc.template_llm {
382            let mut inner = String::new();
383            for c in children {
384                render_block(c, ctx, &mut inner, indent);
385            }
386            let r = expand_template_llm(t, args, &inner);
387            out.push_str(&r);
388            return;
389        }
390    }
391    match name {
392        "callout" => {
393            let kind = args
394                .keyword
395                .get("kind")
396                .and_then(|v| v.as_str())
397                .unwrap_or("info");
398            let _ = writeln!(out, "[!{}]", kind);
399            for c in children {
400                render_block(c, ctx, out, indent);
401            }
402            let _ = writeln!(out, "[/!]");
403        }
404        "details" => {
405            let summary = args
406                .keyword
407                .get("summary")
408                .and_then(|v| v.as_str())
409                .unwrap_or("");
410            let _ = writeln!(out, "[details: \"{}\"]", summary);
411            for c in children {
412                render_block(c, ctx, out, indent);
413            }
414            let _ = writeln!(out, "[/details]");
415        }
416        "math" => {
417            let mut s = String::new();
418            for c in children {
419                render_block(c, ctx, &mut s, indent);
420            }
421            let _ = writeln!(out, "$${}$$", s.trim());
422        }
423        _ => {
424            let _ = writeln!(out, "@{}", name);
425            for c in children {
426                render_block(c, ctx, out, indent);
427            }
428            let _ = writeln!(out, "@end");
429        }
430    }
431}
432
433fn render_inline_seq(seq: &[Inline], ctx: &mut Ctx, out: &mut String) {
434    for n in seq {
435        render_inline(n, ctx, out);
436    }
437}
438
439fn render_inline_seq_to(seq: &[Inline], ctx: &mut Ctx, out: &mut String) {
440    render_inline_seq(seq, ctx, out)
441}
442
443fn render_inline(node: &Inline, ctx: &mut Ctx, out: &mut String) {
444    match node {
445        Inline::Text { value, .. } => out.push_str(value),
446        Inline::HardBreak { .. } => out.push('\n'),
447        Inline::Bold { content, .. } => emph_wrap(content, ctx, out, '*'),
448        Inline::Italic { content, .. } => emph_wrap(content, ctx, out, '_'),
449        Inline::Underline { content, .. } => emph_wrap(content, ctx, out, '+'),
450        Inline::Strike { content, .. } => emph_wrap(content, ctx, out, '~'),
451        Inline::InlineCode { value, .. } => {
452            out.push('`');
453            out.push_str(value);
454            out.push('`');
455        }
456        Inline::Shortcode {
457            name,
458            args,
459            content,
460            span,
461        } if name == "ref" => {
462            let resolved = ctx.resolved_refs.get(span);
463            let display = if let Some(r) = resolved {
464                r.display.clone()
465            } else if let Some(s) = args.keyword.get("title").and_then(|v| v.as_str()) {
466                s.to_string()
467            } else if let Some(s) = args.positional.first().and_then(|v| v.as_str()) {
468                s.to_string()
469            } else if let Some([Inline::Text { value, .. }]) = content.as_deref() {
470                value.clone()
471            } else {
472                String::new()
473            };
474            out.push_str(&display);
475        }
476        Inline::Shortcode {
477            name,
478            args,
479            content,
480            ..
481        } => {
482            // Footnote refs are auto-numbered; the body is emitted in a
483            // dedicated definitions section appended to the document. Inside
484            // a footnote body we degrade nested footnote refs to plain
485            // bracketed text so document-level numbering stays linear.
486            if name == "footnote" {
487                if content.is_none() {
488                    return;
489                }
490                if ctx.in_footnote {
491                    out.push('[');
492                    if let Some(c) = content {
493                        render_inline_seq(c, ctx, out);
494                    }
495                    out.push(']');
496                    return;
497                }
498                ctx.counter += 1;
499                let _ = write!(out, "[^{}]", ctx.counter);
500                return;
501            }
502            render_inline_shortcode_llm(name, args, content.as_deref(), ctx, out);
503        }
504    }
505}
506
507fn emph_wrap(content: &[Inline], ctx: &mut Ctx, out: &mut String, m: char) {
508    if ctx.opts.strip_emphasis {
509        render_inline_seq(content, ctx, out);
510    } else {
511        out.push(m);
512        render_inline_seq(content, ctx, out);
513        out.push(m);
514    }
515}
516
517fn render_inline_shortcode_llm(
518    name: &str,
519    args: &ShortArgs,
520    content: Option<&[Inline]>,
521    ctx: &mut Ctx,
522    out: &mut String,
523) {
524    let inner_string = content.map(|c| {
525        let mut s = String::new();
526        render_inline_seq(c, ctx, &mut s);
527        s
528    });
529    if let Some(sc) = ctx.reg.get(name) {
530        if let Some(t) = &sc.template_llm {
531            let r = expand_template_llm(t, args, inner_string.as_deref().unwrap_or(""));
532            out.push_str(&r);
533            return;
534        }
535    }
536    match name {
537        "link" => {
538            let url = args
539                .keyword
540                .get("url")
541                .and_then(|v| v.as_str())
542                .unwrap_or("");
543            let text = inner_string.as_deref().unwrap_or("");
544            let title = args.keyword.get("title").and_then(|v| v.as_str());
545            if let Some(t) = title {
546                let _ = write!(out, "[{}]({} \"{}\")", text, url, t);
547            } else {
548                let _ = write!(out, "[{}]({})", text, url);
549            }
550        }
551        "image" => {
552            let alt = args
553                .keyword
554                .get("alt")
555                .and_then(|v| v.as_str())
556                .unwrap_or("");
557            if ctx.opts.keep_asset_urls {
558                let src = args
559                    .keyword
560                    .get("src")
561                    .and_then(|v| v.as_str())
562                    .unwrap_or("");
563                let _ = write!(out, "[image: {} {}]", alt, src);
564            } else {
565                let _ = write!(out, "[image: {}]", alt);
566            }
567        }
568        "kbd" => {
569            let _ = write!(out, "[kbd:{}]", inner_string.as_deref().unwrap_or(""));
570        }
571        "sub" => {
572            let _ = write!(out, "[sub:{}]", inner_string.as_deref().unwrap_or(""));
573        }
574        "sup" => {
575            let _ = write!(out, "[sup:{}]", inner_string.as_deref().unwrap_or(""));
576        }
577        "math" => {
578            let _ = write!(out, "${}$", inner_string.as_deref().unwrap_or(""));
579        }
580        _ => {
581            let _ = write!(out, "@{}", name);
582            if let Some(s) = inner_string {
583                let _ = write!(out, "[{}]", s);
584            }
585        }
586    }
587}
588
589fn collect_footnotes(doc: &Document) -> Vec<Vec<Inline>> {
590    let mut out = Vec::new();
591    for b in &doc.blocks {
592        collect_block(b, &mut out);
593    }
594    out
595}
596
597fn collect_block(b: &Block, out: &mut Vec<Vec<Inline>>) {
598    match b {
599        Block::Heading { content, .. } | Block::Paragraph { content, .. } => {
600            for n in content {
601                collect_inline(n, out);
602            }
603        }
604        Block::List { items, .. } => {
605            for it in items {
606                for n in &it.content {
607                    collect_inline(n, out);
608                }
609                for c in &it.children {
610                    collect_block(c, out);
611                }
612            }
613        }
614        Block::Blockquote { children, .. } | Block::BlockShortcode { children, .. } => {
615            for c in children {
616                collect_block(c, out);
617            }
618        }
619        Block::Table { header, rows, .. } => {
620            for cell in &header.cells {
621                for n in cell {
622                    collect_inline(n, out);
623                }
624            }
625            for row in rows {
626                for cell in &row.cells {
627                    for n in cell {
628                        collect_inline(n, out);
629                    }
630                }
631            }
632        }
633        Block::DefinitionList { items, .. } => {
634            for it in items {
635                for n in &it.term {
636                    collect_inline(n, out);
637                }
638                for n in &it.definition {
639                    collect_inline(n, out);
640                }
641            }
642        }
643        Block::CodeBlock { .. } | Block::HorizontalRule { .. } => {}
644    }
645}
646
647fn collect_inline(node: &Inline, out: &mut Vec<Vec<Inline>>) {
648    match node {
649        Inline::Bold { content, .. }
650        | Inline::Italic { content, .. }
651        | Inline::Underline { content, .. }
652        | Inline::Strike { content, .. } => {
653            for n in content {
654                collect_inline(n, out);
655            }
656        }
657        Inline::Shortcode { name, content, .. } => {
658            if name == "footnote" {
659                if let Some(c) = content {
660                    out.push(c.clone());
661                }
662                return;
663            }
664            if let Some(c) = content {
665                for n in c {
666                    collect_inline(n, out);
667                }
668            }
669        }
670        _ => {}
671    }
672}
673
674fn emit_footnotes_section(
675    footnotes: &[Vec<Inline>],
676    reg: &Registry,
677    opts: &Opts,
678    resolved_refs: &std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
679    out: &mut String,
680) {
681    if !out.ends_with('\n') {
682        out.push('\n');
683    }
684    out.push('\n');
685    for (i, body) in footnotes.iter().enumerate() {
686        let n = i + 1;
687        let _ = write!(out, "[^{}]: ", n);
688        let mut ctx = Ctx {
689            reg,
690            opts,
691            counter: 0,
692            in_footnote: true,
693            warnings: Vec::new(),
694            frontmatter_minify_code: None,
695            resolved_refs,
696        };
697        render_inline_seq(body, &mut ctx, out);
698        out.push('\n');
699    }
700}
701
702fn expand_template_llm(tpl: &str, args: &ShortArgs, content: &str) -> String {
703    let mut out = String::new();
704    let bytes = tpl.as_bytes();
705    let mut i = 0;
706    while i < bytes.len() {
707        if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
708            if let Some(rel) = tpl[i + 2..].find("}}") {
709                let key = tpl[i + 2..i + 2 + rel].trim();
710                if key == "content" {
711                    out.push_str(content);
712                } else if let Some(rest) = key.strip_prefix("args.") {
713                    if let Some(v) = args.keyword.get(rest).and_then(|v| v.as_str()) {
714                        out.push_str(v);
715                    }
716                }
717                i = i + 2 + rel + 2;
718                continue;
719            }
720        }
721        out.push(bytes[i] as char);
722        i += 1;
723    }
724    out
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::lexer::lex;
731    use crate::parser::parse;
732    use crate::span::SourceMap;
733
734    fn render_with(input: &str, opts: Opts) -> (String, Vec<String>) {
735        let src = SourceMap::new("d.brf", input);
736        let toks = lex(&src).unwrap();
737        let (doc, diags) = parse(toks, &src);
738        assert!(diags.is_empty(), "{:?}", diags);
739        let reg = Registry::with_builtins();
740        render(&doc, &reg, &opts)
741    }
742
743    fn render_default(input: &str) -> String {
744        render_with(input, Opts::default()).0
745    }
746
747    fn opts_with_keep_metadata() -> Opts {
748        Opts {
749            keep_metadata: true,
750            ..Opts::default()
751        }
752    }
753
754    #[test]
755    fn llm_strips_frontmatter_by_default() {
756        let out = render_default("+++\ntitle = \"hi\"\n+++\n# Doc\n");
757        assert!(!out.contains("+++"), "{}", out);
758        assert!(!out.contains("title"), "{}", out);
759        assert!(out.contains("# Doc"));
760    }
761
762    #[test]
763    fn llm_keeps_frontmatter_with_flag() {
764        let (out, _) = render_with(
765            "+++\ntitle = \"hi\"\n+++\n# Doc\n",
766            opts_with_keep_metadata(),
767        );
768        assert!(
769            out.starts_with("+++\n"),
770            "starts with: {:?}",
771            &out[..20.min(out.len())]
772        );
773        assert!(out.contains("title"));
774        assert!(out.contains("# Doc"));
775        let close_pos = out.find("\n+++\n").expect("closing +++ missing");
776        let doc_pos = out.find("# Doc").expect("body missing");
777        assert!(close_pos < doc_pos, "closing +++ must precede body");
778        assert!(
779            out.contains("+++\n\n"),
780            "blank line after closing +++ missing"
781        );
782    }
783
784    #[test]
785    fn llm_keep_metadata_no_op_when_no_metadata() {
786        let (out, _) = render_with("# Doc\n", opts_with_keep_metadata());
787        assert!(!out.contains("+++"), "{}", out);
788        assert!(out.contains("# Doc"));
789    }
790
791    #[test]
792    fn json_block_minified_by_default() {
793        let (out, w) = render_with(
794            "```json\n{\n  \"a\": 1,\n  \"b\": [1, 2, 3]\n}\n```\n",
795            Opts::default(),
796        );
797        assert!(w.is_empty(), "unexpected warnings: {:?}", w);
798        assert!(out.contains("{\"a\":1,\"b\":[1,2,3]}"), "{}", out);
799        assert!(
800            out.contains("```json"),
801            "fence preserved by default: {}",
802            out
803        );
804    }
805
806    #[test]
807    fn json_block_with_nominify_kept_verbatim() {
808        let src = "```json @nominify\n{\n  \"a\": 1\n}\n```\n";
809        let (out, w) = render_with(src, Opts::default());
810        assert!(w.is_empty());
811        assert!(
812            out.contains("\"a\": 1"),
813            "must preserve whitespace: {}",
814            out
815        );
816    }
817
818    #[test]
819    fn invalid_json_falls_back_with_warning() {
820        let src = "```json\n{ not valid }\n```\n";
821        let (out, w) = render_with(src, Opts::default());
822        assert!(out.contains("{ not valid }"), "verbatim body: {}", out);
823        assert_eq!(w.len(), 1, "expected one B0701 warning");
824        assert!(w[0].contains("B0701"));
825    }
826
827    #[test]
828    fn jsonl_block_minified() {
829        let src = "```jsonl\n{\"a\": 1}\n{\"b\": 2}\n```\n";
830        let (out, w) = render_with(src, Opts::default());
831        assert!(w.is_empty(), "{:?}", w);
832        assert!(out.contains("{\"a\":1}\n{\"b\":2}"), "{}", out);
833    }
834
835    #[test]
836    fn rust_block_minified_in_v0_3() {
837        let src = "```rust\nfn x() {\n    // hi\n    1\n}\n```\n";
838        let (out, w) = render_with(src, Opts::default());
839        assert!(w.is_empty(), "{:?}", w);
840        assert!(out.contains("fn x(){1}"), "minified rust: {}", out);
841        assert!(!out.contains("// hi"), "comment dropped: {}", out);
842    }
843
844    #[test]
845    fn js_block_preserves_newlines() {
846        let src = "```javascript\nfunction add(a, b) {\n    return a + b;\n}\n```\n";
847        let (out, w) = render_with(src, Opts::default());
848        assert!(w.is_empty(), "{:?}", w);
849        assert!(
850            out.contains("function add(a,b){\nreturn a+b;\n}"),
851            "got: {}",
852            out
853        );
854    }
855
856    #[test]
857    fn ts_alias_minifies() {
858        let src = "```ts\nfunction f(x: number): string { return String(x); }\n```\n";
859        let (out, w) = render_with(src, Opts::default());
860        assert!(w.is_empty(), "{:?}", w);
861        assert!(
862            out.contains("function f(x:number):string{return String(x);}"),
863            "got: {}",
864            out
865        );
866    }
867
868    #[test]
869    fn sql_block_minified() {
870        let src = "```sql\nSELECT *\nFROM users\nWHERE id = 1;\n```\n";
871        let (out, w) = render_with(src, Opts::default());
872        assert!(w.is_empty(), "{:?}", w);
873        assert!(
874            out.contains("SELECT*FROM users WHERE id=1;"),
875            "got: {}",
876            out
877        );
878    }
879
880    #[test]
881    fn c_preprocessor_kept_on_own_line() {
882        let src = "```c\n#include <stdio.h>\nint main() { return 0; }\n```\n";
883        let (out, w) = render_with(src, Opts::default());
884        assert!(w.is_empty(), "{:?}", w);
885        assert!(
886            out.contains("#include <stdio.h>\nint main(){return 0;}"),
887            "got: {}",
888            out
889        );
890    }
891
892    #[test]
893    fn cpp_alias_minifies_with_raw_string() {
894        let src = "```cpp\nconst char* s = R\"x(hi)x\";\n```\n";
895        let (out, w) = render_with(src, Opts::default());
896        assert!(w.is_empty(), "{:?}", w);
897        assert!(out.contains("R\"x(hi)x\""), "got: {}", out);
898    }
899
900    #[test]
901    fn java_block_minified() {
902        let src = "```java\npublic class Foo {\n    @Override public void f() {}\n}\n```\n";
903        let (out, w) = render_with(src, Opts::default());
904        assert!(w.is_empty(), "{:?}", w);
905        assert!(
906            out.contains("public class Foo{@Override public void f(){}}"),
907            "got: {}",
908            out
909        );
910    }
911
912    #[test]
913    fn go_block_preserves_newlines() {
914        let src = "```go\nfunc add(a, b int) int {\n    return a + b\n}\n```\n";
915        let (out, w) = render_with(src, Opts::default());
916        assert!(w.is_empty(), "{:?}", w);
917        assert!(
918            out.contains("func add(a,b int)int{\nreturn a+b\n}"),
919            "got: {}",
920            out
921        );
922    }
923
924    #[test]
925    fn keep_comments_emits_b0703() {
926        let src = "```rust @minify-keep-comments\nfn x() {\n    // hi\n    1\n}\n```\n";
927        let (out, w) = render_with(src, Opts::default());
928        assert!(out.contains("/* hi*/"), "comment preserved: {}", out);
929        assert!(
930            w.iter().any(|s| s.contains("B0703")),
931            "B0703 warning emitted: {:?}",
932            w
933        );
934    }
935
936    #[test]
937    fn refused_language_emits_b0704() {
938        // `python` is permanently refused (significant whitespace). With
939        // `@minify` forcing the attempt, the LLM emit pass surfaces a
940        // B0704 error and falls back to verbatim output.
941        let src = "```python @minify\ndef f(x):\n    return x\n```\n";
942        let (out, w) = render_with(src, Opts::default());
943        assert!(out.contains("def f(x):\n    return x"), "verbatim: {}", out);
944        assert!(
945            w.iter().any(|s| s.contains("B0704")),
946            "B0704 emitted: {:?}",
947            w
948        );
949    }
950
951    #[test]
952    fn refused_language_silent_without_optin() {
953        // No `@minify`, default allowlist excludes python — no warning.
954        let opts = Opts {
955            // Make sure python is NOT in the allowlist (default already
956            // excludes it but be explicit for the test's intent).
957            minify_languages: vec!["json".into()],
958            ..Opts::default()
959        };
960        let src = "```python\nx = 1\n```\n";
961        let (_out, w) = render_with(src, opts);
962        assert!(w.is_empty(), "no warning expected: {:?}", w);
963    }
964
965    #[test]
966    fn long_block_emits_b0702() {
967        // Build a 60-line JSON block (each line is its own array element).
968        let mut body = String::from("[\n");
969        for i in 0..60 {
970            body.push_str(&format!("  {}", i));
971            if i < 59 {
972                body.push(',');
973            }
974            body.push('\n');
975        }
976        body.push(']');
977        let src = format!("```json\n{}\n```\n", body);
978        let (_out, w) = render_with(&src, Opts::default());
979        assert!(
980            w.iter().any(|s| s.contains("B0702")),
981            "B0702 emitted for long block: {:?}",
982            w
983        );
984    }
985
986    #[test]
987    fn frontmatter_minify_code_false_disables() {
988        let src = "+++\nminify_code = false\n+++\n```json\n{\"a\": 1}\n```\n";
989        let (out, _) = render_with(src, Opts::default());
990        // Whitespace between key and value is preserved; the minifier did
991        // not run.
992        assert!(out.contains("\"a\": 1"), "verbatim under override: {}", out);
993    }
994
995    #[test]
996    fn frontmatter_override_can_be_force_minified() {
997        // `@minify` on a block overrides the document-level disable.
998        let src = "+++\nminify_code = false\n+++\n```json @minify\n{\"a\": 1}\n```\n";
999        let (out, _) = render_with(src, Opts::default());
1000        assert!(out.contains("{\"a\":1}"), "minified anyway: {}", out);
1001    }
1002
1003    #[test]
1004    fn config_disable_minification_globally() {
1005        let src = "```json\n{\"a\": 1}\n```\n";
1006        let opts = Opts {
1007            minify_code_blocks: false,
1008            ..Opts::default()
1009        };
1010        let (out, _) = render_with(src, opts);
1011        assert!(out.contains("\"a\": 1"), "disabled globally: {}", out);
1012    }
1013
1014    #[test]
1015    fn drop_fence_with_preserve_false() {
1016        let src = "```json\n{\"a\": 1}\n```\n";
1017        let opts = Opts {
1018            preserve_code_fences: false,
1019            ..Opts::default()
1020        };
1021        let (out, _) = render_with(src, opts);
1022        assert!(!out.contains("```"), "fence dropped: {}", out);
1023        assert!(out.contains("{\"a\":1}"));
1024    }
1025
1026    #[test]
1027    fn ref_renders_display_text_only_in_llm_mode() {
1028        use crate::project::ProjectIndex;
1029        use crate::resolve::{ResolveProject, resolve_with_project};
1030        use std::collections::BTreeSet;
1031        use std::path::PathBuf;
1032
1033        // Parse + resolve with project so resolved_refs is populated.
1034        let src = "See @ref[other.brf#x](the spec).\n";
1035        let mut doc = {
1036            use crate::{lexer, parser, span::SourceMap};
1037            let s = SourceMap::new("t.brf", src);
1038            let tokens = lexer::lex(&s).expect("lex");
1039            let (d, _) = parser::parse(tokens, &s);
1040            d
1041        };
1042        let mut idx = ProjectIndex::default();
1043        idx.anchors
1044            .insert("other.brf".to_string(), BTreeSet::from(["x".into()]));
1045        let p = ResolveProject {
1046            index: &idx,
1047            current: &PathBuf::from("here.brf"),
1048        };
1049        let reg = crate::shortcode::Registry::with_builtins();
1050        let _ = resolve_with_project(&mut doc, &reg, Some(&p));
1051
1052        let opts = Opts::default();
1053        let (out, _warnings) = render(&doc, &reg, &opts);
1054        assert!(out.contains("the spec"), "got: {}", out);
1055        assert!(
1056            !out.contains("other.brf"),
1057            "url must be dropped in LLM mode: {}",
1058            out
1059        );
1060        assert!(
1061            !out.contains("#x"),
1062            "anchor must be dropped in LLM mode: {}",
1063            out
1064        );
1065    }
1066
1067    #[test]
1068    fn unresolved_ref_in_llm_falls_back_to_display_text() {
1069        let doc = {
1070            use crate::{lexer, parser, span::SourceMap};
1071            let s = SourceMap::new("t.brf", "See @ref[a.brf](just text).\n");
1072            let tokens = lexer::lex(&s).expect("lex");
1073            let (d, _) = parser::parse(tokens, &s);
1074            d
1075        };
1076        let reg = crate::shortcode::Registry::with_builtins();
1077        let opts = Opts::default();
1078        let (out, _) = render(&doc, &reg, &opts);
1079        assert!(out.contains("just text"), "got: {}", out);
1080        assert!(!out.contains("a.brf"));
1081    }
1082
1083    #[test]
1084    fn dl_renders_verbatim_brief_form() {
1085        use crate::ast::{Block, DefinitionItem, Document, Inline, ShortArgs};
1086        use crate::span::Span;
1087        let doc = Document {
1088            blocks: vec![Block::DefinitionList {
1089                args: ShortArgs::default(),
1090                items: vec![
1091                    DefinitionItem {
1092                        term: vec![Inline::Text {
1093                            value: "Term1".into(),
1094                            span: Span::DUMMY,
1095                        }],
1096                        definition: vec![Inline::Text {
1097                            value: "Def1.".into(),
1098                            span: Span::DUMMY,
1099                        }],
1100                        span: Span::DUMMY,
1101                    },
1102                    DefinitionItem {
1103                        term: vec![Inline::Text {
1104                            value: "Term2".into(),
1105                            span: Span::DUMMY,
1106                        }],
1107                        definition: vec![Inline::Text {
1108                            value: "Def2.".into(),
1109                            span: Span::DUMMY,
1110                        }],
1111                        span: Span::DUMMY,
1112                    },
1113                ],
1114                span: Span::DUMMY,
1115            }],
1116            metadata: None,
1117            resolved_refs: Default::default(),
1118        };
1119        let reg = Registry::with_builtins();
1120        let opts = Opts::default();
1121        let (out, _w) = render(&doc, &reg, &opts);
1122        assert!(out.contains("@dl\n"), "got: {}", out);
1123        assert!(out.contains("Term1\n: Def1.\n"), "got: {}", out);
1124        assert!(out.contains("Term2\n: Def2.\n"), "got: {}", out);
1125        assert!(out.contains("@end\n"), "got: {}", out);
1126    }
1127}