diff --git a/Cargo.toml b/Cargo.toml index 616fe9b4..33aa5c94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,11 @@ path = "./crates/quarto-error-reporting" [workspace.dependencies.quarto-source-map] path = "./crates/quarto-source-map" +[workspace.dependencies.quarto-doctemplate] +path = "./crates/quarto-doctemplate" + +[workspace.dependencies.quarto-treesitter-ast] +path = "./crates/quarto-treesitter-ast" [workspace.lints.clippy] assigning_clones = "warn" diff --git a/crates/pico-quarto-render/Cargo.toml b/crates/pico-quarto-render/Cargo.toml index b0f2469b..9ffc260c 100644 --- a/crates/pico-quarto-render/Cargo.toml +++ b/crates/pico-quarto-render/Cargo.toml @@ -12,9 +12,15 @@ repository.workspace = true [dependencies] quarto-markdown-pandoc = { workspace = true } +quarto-doctemplate = { workspace = true } anyhow.workspace = true clap = { version = "4.0", features = ["derive"] } walkdir = "2.5" +include_dir = "0.7" +rayon = "1.10" + +[dev-dependencies] +quarto-source-map = { workspace = true } [lints] workspace = true diff --git a/crates/pico-quarto-render/README.md b/crates/pico-quarto-render/README.md new file mode 100644 index 00000000..12b3befc --- /dev/null +++ b/crates/pico-quarto-render/README.md @@ -0,0 +1,27 @@ +# pico-quarto-render + +Experimental batch renderer for QMD files to HTML. + +This crate exists for prototyping and experimentation with Quarto's rendering pipeline. It is not intended for production use. + +## Usage + +```bash +pico-quarto-render [-v] +``` + +## Parallelism + +Files are processed in parallel using [Rayon](https://docs.rs/rayon). To control the number of threads, set the `RAYON_NUM_THREADS` environment variable: + +```bash +# Use 4 threads +RAYON_NUM_THREADS=4 pico-quarto-render input/ output/ + +# Use single thread (sequential processing, no rayon overhead) +RAYON_NUM_THREADS=1 pico-quarto-render input/ output/ +``` + +If not set, Rayon defaults to the number of logical CPUs. + +When `RAYON_NUM_THREADS=1`, the code bypasses Rayon entirely and uses a simple sequential loop. This produces cleaner stack traces for profiling. diff --git a/crates/pico-quarto-render/profile.json.gz b/crates/pico-quarto-render/profile.json.gz new file mode 100644 index 00000000..69eb4bbb Binary files /dev/null and b/crates/pico-quarto-render/profile.json.gz differ diff --git a/crates/pico-quarto-render/src/embedded_resolver.rs b/crates/pico-quarto-render/src/embedded_resolver.rs new file mode 100644 index 00000000..5c7893a0 --- /dev/null +++ b/crates/pico-quarto-render/src/embedded_resolver.rs @@ -0,0 +1,88 @@ +/* + * embedded_resolver.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Embedded template resolver for pico-quarto-render. +//! +//! This module provides a `PartialResolver` implementation that loads templates +//! from resources compiled into the binary via `include_dir`. + +use include_dir::{Dir, include_dir}; +use quarto_doctemplate::resolver::{PartialResolver, resolve_partial_path}; +use std::path::Path; + +/// Embedded HTML templates directory. +static HTML_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/resources/html-template"); + +/// Resolver that loads templates from embedded resources. +/// +/// Templates are compiled into the binary at build time using `include_dir`. +/// This resolver implements `PartialResolver` to support partial template loading. +pub struct EmbeddedResolver; + +impl PartialResolver for EmbeddedResolver { + fn get_partial(&self, name: &str, base_path: &Path) -> Option { + // Resolve the partial path following Pandoc rules + let partial_path = resolve_partial_path(name, base_path); + + // Get the filename portion for embedded lookup + // (templates are flat in our structure, so we just need the filename) + let filename = partial_path.file_name()?.to_str()?; + + HTML_TEMPLATES + .get_file(filename) + .and_then(|f| f.contents_utf8()) + .map(|s| s.to_string()) + } +} + +/// Get the main template source. +/// +/// Returns the content of `template.html` from the embedded resources. +pub fn get_main_template() -> Option<&'static str> { + HTML_TEMPLATES + .get_file("template.html") + .and_then(|f| f.contents_utf8()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_main_template() { + let template = get_main_template(); + assert!(template.is_some()); + let content = template.unwrap(); + assert!(content.contains("")); + assert!(content.contains("$body$")); + } + + #[test] + fn test_embedded_resolver_finds_partials() { + let resolver = EmbeddedResolver; + let base_path = Path::new("template.html"); + + // Should find metadata.html partial + let metadata = resolver.get_partial("metadata", base_path); + assert!(metadata.is_some()); + + // Should find title-block.html partial + let title_block = resolver.get_partial("title-block", base_path); + assert!(title_block.is_some()); + + // Should find styles.html partial + let styles = resolver.get_partial("styles", base_path); + assert!(styles.is_some()); + } + + #[test] + fn test_embedded_resolver_missing_partial() { + let resolver = EmbeddedResolver; + let base_path = Path::new("template.html"); + + let missing = resolver.get_partial("nonexistent", base_path); + assert!(missing.is_none()); + } +} diff --git a/crates/pico-quarto-render/src/format_writers.rs b/crates/pico-quarto-render/src/format_writers.rs new file mode 100644 index 00000000..0b8f6e01 --- /dev/null +++ b/crates/pico-quarto-render/src/format_writers.rs @@ -0,0 +1,110 @@ +/* + * format_writers.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Format-specific writers for template context building. +//! +//! This module provides a trait for format-specific AST-to-string conversion, +//! and implementations for HTML output. + +use anyhow::Result; +use quarto_markdown_pandoc::pandoc::block::Block; +use quarto_markdown_pandoc::pandoc::inline::Inlines; + +/// Format-specific writers for converting Pandoc AST to strings. +/// +/// Implementations of this trait provide the format-specific rendering +/// needed when converting document metadata to template values. +pub trait FormatWriters { + /// Write blocks to a string. + fn write_blocks(&self, blocks: &[Block]) -> Result; + + /// Write inlines to a string. + fn write_inlines(&self, inlines: &Inlines) -> Result; +} + +/// HTML format writers. +/// +/// Uses the HTML writer from quarto-markdown-pandoc to convert +/// Pandoc AST nodes to HTML strings. +pub struct HtmlWriters; + +impl FormatWriters for HtmlWriters { + fn write_blocks(&self, blocks: &[Block]) -> Result { + let mut buf = Vec::new(); + quarto_markdown_pandoc::writers::html::write_blocks(blocks, &mut buf)?; + Ok(String::from_utf8_lossy(&buf).into_owned()) + } + + fn write_inlines(&self, inlines: &Inlines) -> Result { + let mut buf = Vec::new(); + quarto_markdown_pandoc::writers::html::write_inlines(inlines, &mut buf)?; + Ok(String::from_utf8_lossy(&buf).into_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use quarto_markdown_pandoc::pandoc::Inline; + use quarto_markdown_pandoc::pandoc::block::Paragraph; + use quarto_markdown_pandoc::pandoc::inline::{Emph, Space, Str}; + + fn dummy_source_info() -> quarto_source_map::SourceInfo { + quarto_source_map::SourceInfo::from_range( + quarto_source_map::FileId(0), + quarto_source_map::Range { + start: quarto_source_map::Location { + offset: 0, + row: 0, + column: 0, + }, + end: quarto_source_map::Location { + offset: 0, + row: 0, + column: 0, + }, + }, + ) + } + + #[test] + fn test_html_writers_inlines() { + let writers = HtmlWriters; + let inlines = vec![ + Inline::Str(Str { + text: "Hello".to_string(), + source_info: dummy_source_info(), + }), + Inline::Space(Space { + source_info: dummy_source_info(), + }), + Inline::Emph(Emph { + content: vec![Inline::Str(Str { + text: "world".to_string(), + source_info: dummy_source_info(), + })], + source_info: dummy_source_info(), + }), + ]; + + let result = writers.write_inlines(&inlines).unwrap(); + assert_eq!(result, "Hello world"); + } + + #[test] + fn test_html_writers_blocks() { + let writers = HtmlWriters; + let blocks = vec![Block::Paragraph(Paragraph { + content: vec![Inline::Str(Str { + text: "A paragraph.".to_string(), + source_info: dummy_source_info(), + })], + source_info: dummy_source_info(), + })]; + + let result = writers.write_blocks(&blocks).unwrap(); + assert_eq!(result, "

A paragraph.

\n"); + } +} diff --git a/crates/pico-quarto-render/src/main.rs b/crates/pico-quarto-render/src/main.rs index d656212f..3e640b2a 100644 --- a/crates/pico-quarto-render/src/main.rs +++ b/crates/pico-quarto-render/src/main.rs @@ -5,12 +5,53 @@ * Experimental prototype for rendering QMD files to HTML */ +mod embedded_resolver; +mod format_writers; +mod template_context; + use anyhow::{Context, Result}; use clap::Parser; +use rayon::prelude::*; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use walkdir::WalkDir; +use embedded_resolver::EmbeddedResolver; +use format_writers::HtmlWriters; +use quarto_doctemplate::Template; +use template_context::{compile_template, prepare_template_metadata, render_with_template}; + +/// Result of processing a single QMD file. +struct ProcessResult { + /// The input path that was processed. + input_path: PathBuf, + /// The result: Ok(output_path) or Err(error). + result: Result, +} + +/// Thread-safe wrapper for Template. +/// +/// Template contains SourceInfo which uses Rc internally, making it not Sync/Send. +/// We wrap it and use unsafe to assert it's safe to share across threads. +/// +/// # Safety +/// +/// This is safe because: +/// 1. We only read from the template during parallel processing (no mutation) +/// 2. The Rc inside SourceInfo is never incremented/decremented after template compilation +/// 3. Template::render() only reads the AST nodes, it doesn't modify them +/// 4. Each thread gets a shared reference and doesn't modify the template +/// +/// The underlying issue is that SourceInfo uses Rc for substring tracking, +/// but in the template context, these Rc values are created once during parsing +/// and never mutated afterward. The template evaluation is purely read-only. +struct SendSyncTemplate(Template); + +// SAFETY: See struct documentation above. +unsafe impl Sync for SendSyncTemplate {} +unsafe impl Send for SendSyncTemplate {} + #[derive(Parser, Debug)] #[command(name = "pico-quarto-render")] #[command(about = "Experimental QMD to HTML batch renderer")] @@ -65,25 +106,57 @@ fn main() -> Result<()> { eprintln!("Found {} .qmd files", qmd_files.len()); } - // Process each file + // Compile template once (shared across all threads) + let template_source = embedded_resolver::get_main_template() + .ok_or_else(|| anyhow::anyhow!("Main template not found"))?; + let resolver = EmbeddedResolver; + let template = compile_template(template_source, &resolver)?; + + // Check if single-threaded mode is requested (for cleaner profiling) + let single_threaded = std::env::var("RAYON_NUM_THREADS") + .map(|v| v == "1") + .unwrap_or(false); + + // Process files, collecting results + let results: Vec = if single_threaded { + // Sequential processing (cleaner stack traces for profiling) + qmd_files + .iter() + .map(|qmd_path| ProcessResult { + input_path: qmd_path.clone(), + result: process_qmd_file(qmd_path, &args.input_dir, &args.output_dir, &template), + }) + .collect() + } else { + // Parallel processing with rayon + let shared_template = Arc::new(SendSyncTemplate(template)); + qmd_files + .par_iter() + .map(|qmd_path| { + let template = &shared_template.0; + ProcessResult { + input_path: qmd_path.clone(), + result: process_qmd_file(qmd_path, &args.input_dir, &args.output_dir, template), + } + }) + .collect() + }; + + // Output results sequentially (preserves order, no interleaving) let mut success_count = 0; let mut error_count = 0; - for qmd_path in qmd_files { - // Print filename at verbose level 1+ - if args.verbose >= 1 { - eprintln!("Rendering {:?}", qmd_path); - } - - match process_qmd_file(&qmd_path, &args.input_dir, &args.output_dir, args.verbose) { + for process_result in results { + match process_result.result { Ok(output_path) => { if args.verbose >= 1 { + eprintln!("Rendered {:?}", process_result.input_path); eprintln!(" -> {:?}", output_path); } success_count += 1; } Err(e) => { - eprintln!("✗ Error processing {:?}: {}", qmd_path, e); + eprintln!("✗ Error processing {:?}: {}", process_result.input_path, e); error_count += 1; } } @@ -107,21 +180,16 @@ fn process_qmd_file( qmd_path: &Path, input_dir: &Path, output_dir: &Path, - verbose: u8, + template: &Template, ) -> Result { // Read the input file let input_content = fs::read(qmd_path).context(format!("Failed to read file: {:?}", qmd_path))?; // Parse QMD to AST - // Enable parser verbose mode at level 2+ - let mut output_stream: Box = if verbose >= 2 { - Box::new(std::io::stderr()) - } else { - Box::new(std::io::sink()) - }; + let mut output_stream = std::io::sink(); - let (pandoc, _context, warnings) = quarto_markdown_pandoc::readers::qmd::read( + let (mut pandoc, _context, _warnings) = quarto_markdown_pandoc::readers::qmd::read( &input_content, false, // loose mode qmd_path.to_str().unwrap_or(""), @@ -139,17 +207,12 @@ fn process_qmd_file( anyhow::anyhow!("Parse errors:\n{}", error_text) })?; - // Log warnings if verbose - if verbose >= 2 { - for warning in warnings { - eprintln!("Warning: {}", warning.to_text(None)); - } - } + // Prepare template metadata (adds pagetitle from title, etc.) + prepare_template_metadata(&mut pandoc); - // Convert AST to HTML - let mut html_buf = Vec::new(); - quarto_markdown_pandoc::writers::html::write(&pandoc, &mut html_buf) - .context("Failed to write HTML")?; + // Render with template + let writers = HtmlWriters; + let html_output = render_with_template(&pandoc, template, &writers)?; // Determine output path let relative_path = qmd_path @@ -166,7 +229,7 @@ fn process_qmd_file( } // Write HTML to output file - fs::write(&output_path, html_buf) + fs::write(&output_path, &html_output) .context(format!("Failed to write output file: {:?}", output_path))?; Ok(output_path) diff --git a/crates/pico-quarto-render/src/resources/html-template/html.styles b/crates/pico-quarto-render/src/resources/html-template/html.styles new file mode 100644 index 00000000..9e253e3e --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/html.styles @@ -0,0 +1,209 @@ +$if(document-css)$ +html { +$if(mainfont)$ + font-family: $mainfont$; +$endif$ +$if(fontsize)$ + font-size: $fontsize$; +$endif$ +$if(linestretch)$ + line-height: $linestretch$; +$endif$ + color: $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; + background-color: $if(backgroundcolor)$$backgroundcolor$$else$#fdfdfd$endif$; +} +body { + margin: 0 auto; + max-width: $if(maxwidth)$$maxwidth$$else$36em$endif$; + padding-left: $if(margin-left)$$margin-left$$else$50px$endif$; + padding-right: $if(margin-right)$$margin-right$$else$50px$endif$; + padding-top: $if(margin-top)$$margin-top$$else$50px$endif$; + padding-bottom: $if(margin-bottom)$$margin-bottom$$else$50px$endif$; + hyphens: auto; + overflow-wrap: break-word; + text-rendering: optimizeLegibility; + font-kerning: normal; +} +@media (max-width: 600px) { + body { + font-size: 0.9em; + padding: 12px; + } + h1 { + font-size: 1.8em; + } +} +@media print { + html { + background-color: $if(backgroundcolor)$$backgroundcolor$$else$white$endif$; + } + body { + background-color: transparent; + color: black; + font-size: 12pt; + } + p, h2, h3 { + orphans: 3; + widows: 3; + } + h2, h3, h4 { + page-break-after: avoid; + } +} +p { + margin: 1em 0; +} +a { + color: $if(linkcolor)$$linkcolor$$else$#1a1a1a$endif$; +} +a:visited { + color: $if(linkcolor)$$linkcolor$$else$#1a1a1a$endif$; +} +img { + max-width: 100%; +} +svg { + height: auto; + max-width: 100%; +} +h1, h2, h3, h4, h5, h6 { + margin-top: 1.4em; +} +h5, h6 { + font-size: 1em; + font-style: italic; +} +h6 { + font-weight: normal; +} +ol, ul { + padding-left: 1.7em; + margin-top: 1em; +} +li > ol, li > ul { + margin-top: 0; +} +blockquote { + margin: 1em 0 1em 1.7em; + padding-left: 1em; + border-left: 2px solid #e6e6e6; + color: #606060; +} +$if(abstract)$ +div.abstract { + margin: 2em 2em 2em 2em; + text-align: left; + font-size: 85%; +} +div.abstract-title { + font-weight: bold; + text-align: center; + padding: 0; + margin-bottom: 0.5em; +} +$endif$ +code { + font-family: $if(monofont)$$monofont$$else$Menlo, Monaco, Consolas, 'Lucida Console', monospace$endif$; +$if(monobackgroundcolor)$ + background-color: $monobackgroundcolor$; + padding: .2em .4em; +$endif$ + font-size: 85%; + margin: 0; + hyphens: manual; +} +pre { + margin: 1em 0; +$if(monobackgroundcolor)$ + background-color: $monobackgroundcolor$; + padding: 1em; +$endif$ + overflow: auto; +} +pre code { + padding: 0; + overflow: visible; + overflow-wrap: normal; +} +.sourceCode { + background-color: transparent; + overflow: visible; +} +hr { + border: none; + border-top: 1px solid #1a1a1a; + height: 1px; + margin: 1em 0; +} +table { + margin: 1em 0; + border-collapse: collapse; + width: 100%; + overflow-x: auto; + display: block; + font-variant-numeric: lining-nums tabular-nums; +} +table caption { +$if(table-caption-below)$ + caption-side: bottom; + margin-top: 0.75em; +$else$ + margin-bottom: 0.75em; +$endif$ +} +tbody { + margin-top: 0.5em; + border-top: 1px solid $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; + border-bottom: 1px solid $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; +} +th { + border-top: 1px solid $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; + padding: 0.25em 0.5em 0.25em 0.5em; +} +td { + padding: 0.125em 0.5em 0.25em 0.5em; +} +header { + margin-bottom: 4em; + text-align: center; +} +#TOC li { + list-style: none; +} +#TOC ul { + padding-left: 1.3em; +} +#TOC > ul { + padding-left: 0; +} +#TOC a:not(:hover) { + text-decoration: none; +} +$endif$ +code{white-space: pre-wrap;} +span.smallcaps{font-variant: small-caps;} +div.columns{display: flex; gap: min(4vw, 1.5em);} +div.column{flex: auto; overflow-x: auto;} +div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} +/* The extra [class] is a hack that increases specificity enough to + override a similar rule in reveal.js */ +ul.task-list[class]{list-style: none;} +ul.task-list li input[type="checkbox"] { + font-size: inherit; + width: 0.8em; + margin: 0 0.8em 0.2em -1.6em; + vertical-align: middle; +} +$if(quotes)$ +q { quotes: "“" "”" "‘" "’"; } +$endif$ +$if(displaymath-css)$ +.display.math{display: block; text-align: center; margin: 0.5rem auto;} +$endif$ +$if(highlighting-css)$ +/* CSS for syntax highlighting */ +$highlighting-css$ +$endif$ +$if(csl-css)$ +$styles.citations.html()$ +$endif$ diff --git a/crates/pico-quarto-render/src/resources/html-template/html.template b/crates/pico-quarto-render/src/resources/html-template/html.template new file mode 100644 index 00000000..26740d1b --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/html.template @@ -0,0 +1,70 @@ + + + + + + +$for(author-meta)$ + +$endfor$ +$if(date-meta)$ + +$endif$ +$if(keywords)$ + +$endif$ +$if(description-meta)$ + +$endif$ + $if(title-prefix)$$title-prefix$ – $endif$$pagetitle$ + +$for(css)$ + +$endfor$ +$for(header-includes)$ + $header-includes$ +$endfor$ +$if(math)$ + $math$ +$endif$ + + +$for(include-before)$ +$include-before$ +$endfor$ +$if(title)$ +
+

$title$

+$if(subtitle)$ +

$subtitle$

+$endif$ +$for(author)$ +

$author$

+$endfor$ +$if(date)$ +

$date$

+$endif$ +$if(abstract)$ +
+
$abstract-title$
+$abstract$ +
+$endif$ +
+$endif$ +$if(toc)$ + +$endif$ +$body$ +$for(include-after)$ +$include-after$ +$endfor$ + + diff --git a/crates/pico-quarto-render/src/resources/html-template/metadata.html b/crates/pico-quarto-render/src/resources/html-template/metadata.html new file mode 100644 index 00000000..6979f6f7 --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/metadata.html @@ -0,0 +1,23 @@ + +$if(quarto-version)$ + +$else$ + +$endif$ + + + +$for(author-meta)$ + +$endfor$ +$if(date-meta)$ + +$endif$ +$if(keywords)$ + +$endif$ +$if(description-meta)$ + +$endif$ + +$pagetitle$$if(title-prefix)$ – $title-prefix$$endif$ \ No newline at end of file diff --git a/crates/pico-quarto-render/src/resources/html-template/styles.citations.html b/crates/pico-quarto-render/src/resources/html-template/styles.citations.html new file mode 100644 index 00000000..6da3c5dd --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/styles.citations.html @@ -0,0 +1,21 @@ +/* CSS for citations */ +div.csl-bib-body { } +div.csl-entry { + clear: both; + margin-bottom: 0px; +} +.hanging div.csl-entry { + margin-left:2em; + text-indent:-2em; +} +div.csl-left-margin { + min-width:2em; + float:left; +} +div.csl-right-inline { + margin-left:2em; + padding-left:1em; +} +div.csl-indent { + margin-left: 2em; +} diff --git a/crates/pico-quarto-render/src/resources/html-template/styles.html b/crates/pico-quarto-render/src/resources/html-template/styles.html new file mode 100644 index 00000000..a10531a9 --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/styles.html @@ -0,0 +1,213 @@ +$if(document-css)$ +html { +$if(mainfont)$ + font-family: $mainfont$; +$endif$ +$if(fontsize)$ + font-size: $fontsize$; +$endif$ +$if(linestretch)$ + line-height: $linestretch$; +$endif$ + color: $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; + background-color: $if(backgroundcolor)$$backgroundcolor$$else$#fdfdfd$endif$; +} +body { + margin: 0 auto; + max-width: $if(maxwidth)$$maxwidth$$else$36em$endif$; + padding-left: $if(margin-left)$$margin-left$$else$50px$endif$; + padding-right: $if(margin-right)$$margin-right$$else$50px$endif$; + padding-top: $if(margin-top)$$margin-top$$else$50px$endif$; + padding-bottom: $if(margin-bottom)$$margin-bottom$$else$50px$endif$; + hyphens: auto; + overflow-wrap: break-word; + text-rendering: optimizeLegibility; + font-kerning: normal; +} +@media (max-width: 600px) { + body { + font-size: 0.9em; + padding: 12px; + } + h1 { + font-size: 1.8em; + } +} +@media print { + html { + background-color: $if(backgroundcolor)$$backgroundcolor$$else$white$endif$; + } + body { + background-color: transparent; + color: black; + font-size: 12pt; + } + p, h2, h3 { + orphans: 3; + widows: 3; + } + h2, h3, h4 { + page-break-after: avoid; + } +} +p { + margin: 1em 0; +} +a { + color: $if(linkcolor)$$linkcolor$$else$#1a1a1a$endif$; +} +a:visited { + color: $if(linkcolor)$$linkcolor$$else$#1a1a1a$endif$; +} +img { + max-width: 100%; +} +svg { + height; auto; + max-width: 100%; +} +h1, h2, h3, h4, h5, h6 { + margin-top: 1.4em; +} +h5, h6 { + font-size: 1em; + font-style: italic; +} +h6 { + font-weight: normal; +} +ol, ul { + padding-left: 1.7em; + margin-top: 1em; +} +li > ol, li > ul { + margin-top: 0; +} +ul > li:not(:has(> p)) > ul, +ol > li:not(:has(> p)) > ul, +ul > li:not(:has(> p)) > ol, +ol > li:not(:has(> p)) > ol { + margin-bottom: 0; +} +ul > li:not(:has(> p)) > ul > li:has(> p), +ol > li:not(:has(> p)) > ul > li:has(> p), +ul > li:not(:has(> p)) > ol > li:has(> p), +ol > li:not(:has(> p)) > ol > li:has(> p) { + margin-top: 1rem; +} +blockquote { + margin: 1em 0 1em 1.7em; + padding-left: 1em; + border-left: 2px solid #e6e6e6; + color: #606060; +} +$if(abstract)$ +div.abstract { + margin: 2em 2em 2em 2em; + text-align: left; + font-size: 85%; +} +div.abstract-title { + font-weight: bold; + text-align: center; + padding: 0; + margin-bottom: 0.5em; +} +$endif$ +code { + font-family: $if(monofont)$$monofont$$else$Menlo, Monaco, Consolas, 'Lucida Console', monospace$endif$; +$if(monobackgroundcolor)$ + background-color: $monobackgroundcolor$; + padding: .2em .4em; +$endif$ + font-size: 85%; + margin: 0; + hyphens: manual; +} +pre { + margin: 1em 0; +$if(monobackgroundcolor)$ + background-color: $monobackgroundcolor$; + padding: 1em; +$endif$ + overflow: auto; +} +pre code { + padding: 0; + overflow: visible; + overflow-wrap: normal; +} +.sourceCode { + background-color: transparent; + overflow: visible; +} +hr { + border: none; + border-top: 1px solid #1a1a1a; + height: 1px; + margin: 1em 0; +} +table { + margin: 1em 0; + border-collapse: collapse; + width: 100%; + overflow-x: auto; + display: block; + font-variant-numeric: lining-nums tabular-nums; +} +table caption { + margin-bottom: 0.75em; +} +tbody { + margin-top: 0.5em; + border-top: 1px solid $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; + border-bottom: 1px solid $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; +} +th { + border-top: 1px solid $if(fontcolor)$$fontcolor$$else$#1a1a1a$endif$; + padding: 0.25em 0.5em 0.25em 0.5em; +} +td { + padding: 0.125em 0.5em 0.25em 0.5em; +} +header { + margin-bottom: 4em; + text-align: center; +} +#TOC li { + list-style: none; +} +#TOC ul { + padding-left: 1.3em; +} +#TOC > ul { + padding-left: 0; +} +#TOC a:not(:hover) { + text-decoration: none; +} +$endif$ +code{white-space: pre-wrap;} +span.smallcaps{font-variant: small-caps;} +div.columns{display: flex; gap: min(4vw, 1.5em);} +div.column{flex: auto; overflow-x: auto;} +div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} +ul.task-list{list-style: none;} +ul.task-list li input[type="checkbox"] { + width: 0.8em; + margin: 0 0.8em 0.2em -1em; /* quarto-specific, see https://github.com/quarto-dev/quarto-cli/issues/4556 */ + vertical-align: middle; +} +$if(quotes)$ +q { quotes: "“" "”" "‘" "’"; } +$endif$ +$if(displaymath-css)$ +.display.math{display: block; text-align: center; margin: 0.5rem auto;} +$endif$ +$if(highlighting-css)$ +/* CSS for syntax highlighting */ +$highlighting-css$ +$endif$ +$if(csl-css)$ +$styles.citations.html()$ +$endif$ diff --git a/crates/pico-quarto-render/src/resources/html-template/template.html b/crates/pico-quarto-render/src/resources/html-template/template.html new file mode 100644 index 00000000..3a7b1b96 --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/template.html @@ -0,0 +1,95 @@ + + + + + +$metadata.html()$ + + + + +$for(header-includes)$ +$header-includes$ +$endfor$ + +$if(math)$ +$if(mathjax)$ + +$endif$ + $math$ + + +$endif$ + +$for(css)$ + +$endfor$ + + + + +$for(include-before)$ +$include-before$ +$endfor$ + +$if(title)$ +$title-block.html()$ +$elseif(subtitle)$ +$title-block.html()$ +$elseif(by-author)$ +$title-block.html()$ +$elseif(date)$ +$title-block.html()$ +$elseif(categories)$ +$title-block.html()$ +$elseif(date-modified)$ +$title-block.html()$ +$elseif(doi)$ +$title-block.html()$ +$elseif(abstract)$ +$title-block.html()$ +$elseif(keywords)$ +$title-block.html()$ +$endif$ + + +$if(toc)$ +$toc.html()$ +$endif$ + +$body$ + +$for(include-after)$ +$include-after$ +$endfor$ + + + + diff --git a/crates/pico-quarto-render/src/resources/html-template/title-block.html b/crates/pico-quarto-render/src/resources/html-template/title-block.html new file mode 100644 index 00000000..ba44b8c6 --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/title-block.html @@ -0,0 +1,19 @@ +
+$if(title)$

$title$

$endif$ +$if(subtitle)$ +

$subtitle$

+$endif$ +$for(author)$ +

$author$

+$endfor$ + +$if(date)$ +

$date$

+$endif$ +$if(abstract)$ +
+
$abstract-title$
+$abstract$ +
+$endif$ +
diff --git a/crates/pico-quarto-render/src/resources/html-template/toc.html b/crates/pico-quarto-render/src/resources/html-template/toc.html new file mode 100644 index 00000000..2b7c7c8f --- /dev/null +++ b/crates/pico-quarto-render/src/resources/html-template/toc.html @@ -0,0 +1,6 @@ + diff --git a/crates/pico-quarto-render/src/template_context.rs b/crates/pico-quarto-render/src/template_context.rs new file mode 100644 index 00000000..e354a99f --- /dev/null +++ b/crates/pico-quarto-render/src/template_context.rs @@ -0,0 +1,364 @@ +/* + * template_context.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Template context building and metadata preparation. +//! +//! This module provides functions for: +//! - Preparing document metadata for template rendering (`prepare_template_metadata`) +//! - Converting Pandoc metadata to template values (`meta_to_template_value`) +//! - The main rendering function (`render_with_template`) + +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Result; +use quarto_doctemplate::{Template, TemplateContext, TemplateValue}; +use quarto_markdown_pandoc::pandoc::Pandoc; +use quarto_markdown_pandoc::pandoc::meta::{MetaMapEntry, MetaValueWithSourceInfo}; + +use crate::format_writers::FormatWriters; + +/// Prepare document metadata for template rendering. +/// +/// This mutates the document to add derived metadata fields: +/// - `pagetitle`: Plain-text version of `title` (for HTML `` element) +/// +/// More fields can be added in the future (author-meta, date-meta, etc.) +pub fn prepare_template_metadata(pandoc: &mut Pandoc) { + // Only mutate if meta is a MetaMap + let MetaValueWithSourceInfo::MetaMap { + entries, + source_info, + } = &mut pandoc.meta + else { + return; + }; + + // Check if pagetitle already exists + let has_pagetitle = entries.iter().any(|e| e.key == "pagetitle"); + if has_pagetitle { + return; + } + + // Look for title field + let title_entry = entries.iter().find(|e| e.key == "title"); + if let Some(entry) = title_entry { + let plain_text = match &entry.value { + MetaValueWithSourceInfo::MetaString { value, .. } => value.clone(), + MetaValueWithSourceInfo::MetaInlines { content, .. } => { + let (text, _diagnostics) = + quarto_markdown_pandoc::writers::plaintext::inlines_to_string(content); + text + } + MetaValueWithSourceInfo::MetaBlocks { content, .. } => { + let (text, _diagnostics) = + quarto_markdown_pandoc::writers::plaintext::blocks_to_string(content); + text + } + _ => return, // Other types: skip + }; + + // Add pagetitle entry + entries.push(MetaMapEntry { + key: "pagetitle".to_string(), + key_source: source_info.clone(), + value: MetaValueWithSourceInfo::MetaString { + value: plain_text, + source_info: source_info.clone(), + }, + }); + } +} + +/// Convert document metadata to template values. +/// +/// This recursively converts the metadata structure: +/// - MetaString → TemplateValue::String (literal, no rendering) +/// - MetaBool → TemplateValue::Bool +/// - MetaInlines → TemplateValue::String (rendered via format writers) +/// - MetaBlocks → TemplateValue::String (rendered via format writers) +/// - MetaList → TemplateValue::List (recursive) +/// - MetaMap → TemplateValue::Map (recursive) +pub fn meta_to_template_value<W: FormatWriters>( + meta: &MetaValueWithSourceInfo, + writers: &W, +) -> Result<TemplateValue> { + Ok(match meta { + MetaValueWithSourceInfo::MetaString { value, .. } => { + // MetaString is already a plain string - use as literal + TemplateValue::String(value.clone()) + } + MetaValueWithSourceInfo::MetaBool { value, .. } => TemplateValue::Bool(*value), + MetaValueWithSourceInfo::MetaInlines { content, .. } => { + // Render inlines using format-specific writer + TemplateValue::String(writers.write_inlines(content)?) + } + MetaValueWithSourceInfo::MetaBlocks { content, .. } => { + // Render blocks using format-specific writer + TemplateValue::String(writers.write_blocks(content)?) + } + MetaValueWithSourceInfo::MetaList { items, .. } => { + let values: Result<Vec<_>> = items + .iter() + .map(|item| meta_to_template_value(item, writers)) + .collect(); + TemplateValue::List(values?) + } + MetaValueWithSourceInfo::MetaMap { entries, .. } => { + let mut map = HashMap::new(); + for entry in entries { + map.insert( + entry.key.clone(), + meta_to_template_value(&entry.value, writers)?, + ); + } + TemplateValue::Map(map) + } + }) +} + +/// Render a document using a template. +/// +/// # Arguments +/// - `pandoc` - The document (should have been through prepare_template_metadata) +/// - `template` - A compiled template with partials resolved +/// - `writers` - Format-specific writers for metadata conversion +/// +/// # Returns +/// The rendered document as a string, or an error. +pub fn render_with_template<W: FormatWriters>( + pandoc: &Pandoc, + template: &Template, + writers: &W, +) -> Result<String> { + // 1. Convert metadata to TemplateValue::Map + let meta_value = meta_to_template_value(&pandoc.meta, writers)?; + + // 2. Build TemplateContext from metadata + let mut context = TemplateContext::new(); + if let TemplateValue::Map(map) = meta_value { + for (key, value) in map { + context.insert(key, value); + } + } + + // 3. Render document body and add to context + let body = writers.write_blocks(&pandoc.blocks)?; + context.insert("body", TemplateValue::String(body)); + + // 4. Evaluate template + let output = template + .render(&context) + .map_err(|e| anyhow::anyhow!("Template error: {:?}", e))?; + + Ok(output) +} + +/// Compile a template from the embedded resources. +/// +/// # Arguments +/// - `template_source` - The main template source +/// - `resolver` - Partial resolver for loading includes +/// +/// # Returns +/// The compiled template, or an error. +pub fn compile_template<R: quarto_doctemplate::PartialResolver>( + template_source: &str, + resolver: &R, +) -> Result<Template> { + Template::compile_with_resolver(template_source, Path::new("template.html"), resolver, 0) + .map_err(|e| anyhow::anyhow!("Template compilation error: {:?}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use quarto_markdown_pandoc::pandoc::Inline; + use quarto_markdown_pandoc::pandoc::inline::Str; + + fn dummy_source_info() -> quarto_source_map::SourceInfo { + quarto_source_map::SourceInfo::from_range( + quarto_source_map::FileId(0), + quarto_source_map::Range { + start: quarto_source_map::Location { + offset: 0, + row: 0, + column: 0, + }, + end: quarto_source_map::Location { + offset: 0, + row: 0, + column: 0, + }, + }, + ) + } + + #[test] + fn test_prepare_template_metadata_adds_pagetitle() { + let mut pandoc = Pandoc { + meta: MetaValueWithSourceInfo::MetaMap { + entries: vec![MetaMapEntry { + key: "title".to_string(), + key_source: dummy_source_info(), + value: MetaValueWithSourceInfo::MetaInlines { + content: vec![Inline::Str(Str { + text: "My Document".to_string(), + source_info: dummy_source_info(), + })], + source_info: dummy_source_info(), + }, + }], + source_info: dummy_source_info(), + }, + blocks: vec![], + }; + + prepare_template_metadata(&mut pandoc); + + // Check that pagetitle was added + if let MetaValueWithSourceInfo::MetaMap { entries, .. } = &pandoc.meta { + let pagetitle = entries.iter().find(|e| e.key == "pagetitle"); + assert!(pagetitle.is_some()); + if let Some(entry) = pagetitle { + if let MetaValueWithSourceInfo::MetaString { value, .. } = &entry.value { + assert_eq!(value, "My Document"); + } else { + panic!("Expected MetaString for pagetitle"); + } + } + } else { + panic!("Expected MetaMap"); + } + } + + #[test] + fn test_prepare_template_metadata_preserves_existing_pagetitle() { + let mut pandoc = Pandoc { + meta: MetaValueWithSourceInfo::MetaMap { + entries: vec![ + MetaMapEntry { + key: "title".to_string(), + key_source: dummy_source_info(), + value: MetaValueWithSourceInfo::MetaInlines { + content: vec![Inline::Str(Str { + text: "My Document".to_string(), + source_info: dummy_source_info(), + })], + source_info: dummy_source_info(), + }, + }, + MetaMapEntry { + key: "pagetitle".to_string(), + key_source: dummy_source_info(), + value: MetaValueWithSourceInfo::MetaString { + value: "Custom Page Title".to_string(), + source_info: dummy_source_info(), + }, + }, + ], + source_info: dummy_source_info(), + }, + blocks: vec![], + }; + + prepare_template_metadata(&mut pandoc); + + // Check that pagetitle was NOT overwritten + if let MetaValueWithSourceInfo::MetaMap { entries, .. } = &pandoc.meta { + let pagetitle_entries: Vec<_> = + entries.iter().filter(|e| e.key == "pagetitle").collect(); + assert_eq!(pagetitle_entries.len(), 1); + if let MetaValueWithSourceInfo::MetaString { value, .. } = &pagetitle_entries[0].value { + assert_eq!(value, "Custom Page Title"); + } + } + } + + #[test] + fn test_meta_to_template_value_string() { + use crate::format_writers::HtmlWriters; + let writers = HtmlWriters; + + let meta = MetaValueWithSourceInfo::MetaString { + value: "hello".to_string(), + source_info: dummy_source_info(), + }; + + let result = meta_to_template_value(&meta, &writers).unwrap(); + assert_eq!(result, TemplateValue::String("hello".to_string())); + } + + #[test] + fn test_meta_to_template_value_bool() { + use crate::format_writers::HtmlWriters; + let writers = HtmlWriters; + + let meta = MetaValueWithSourceInfo::MetaBool { + value: true, + source_info: dummy_source_info(), + }; + + let result = meta_to_template_value(&meta, &writers).unwrap(); + assert_eq!(result, TemplateValue::Bool(true)); + } + + #[test] + fn test_meta_to_template_value_inlines() { + use crate::format_writers::HtmlWriters; + let writers = HtmlWriters; + + let meta = MetaValueWithSourceInfo::MetaInlines { + content: vec![Inline::Str(Str { + text: "hello".to_string(), + source_info: dummy_source_info(), + })], + source_info: dummy_source_info(), + }; + + let result = meta_to_template_value(&meta, &writers).unwrap(); + // HTML writer outputs plain text for Str + assert_eq!(result, TemplateValue::String("hello".to_string())); + } + + #[test] + fn test_meta_to_template_value_map() { + use crate::format_writers::HtmlWriters; + let writers = HtmlWriters; + + let meta = MetaValueWithSourceInfo::MetaMap { + entries: vec![ + MetaMapEntry { + key: "key1".to_string(), + key_source: dummy_source_info(), + value: MetaValueWithSourceInfo::MetaString { + value: "value1".to_string(), + source_info: dummy_source_info(), + }, + }, + MetaMapEntry { + key: "key2".to_string(), + key_source: dummy_source_info(), + value: MetaValueWithSourceInfo::MetaBool { + value: false, + source_info: dummy_source_info(), + }, + }, + ], + source_info: dummy_source_info(), + }; + + let result = meta_to_template_value(&meta, &writers).unwrap(); + if let TemplateValue::Map(map) = result { + assert_eq!( + map.get("key1"), + Some(&TemplateValue::String("value1".to_string())) + ); + assert_eq!(map.get("key2"), Some(&TemplateValue::Bool(false))); + } else { + panic!("Expected TemplateValue::Map"); + } + } +} diff --git a/crates/pico-quarto-render/tests/end_to_end.rs b/crates/pico-quarto-render/tests/end_to_end.rs new file mode 100644 index 00000000..4a0b12e3 --- /dev/null +++ b/crates/pico-quarto-render/tests/end_to_end.rs @@ -0,0 +1,230 @@ +/* + * end_to_end.rs + * Copyright (c) 2025 Posit, PBC + * + * End-to-end tests for pico-quarto-render HTML output. + */ + +use std::path::Path; + +/// Helper to get the path to test fixtures +fn fixture_path(name: &str) -> std::path::PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest_dir).join("tests/fixtures").join(name) +} + +/// Render a QMD file to HTML using the full pipeline. +fn render_qmd_to_html(fixture_name: &str) -> String { + use quarto_doctemplate::Template; + use std::fs; + + // These are the same modules used in main.rs, but we access them via the crate + // Since they're private, we'll replicate the minimal logic here for testing + + let qmd_path = fixture_path(fixture_name); + let input_content = fs::read(&qmd_path).expect("Failed to read fixture"); + + // Parse QMD + let mut output_stream = std::io::sink(); + let (mut pandoc, _context, _warnings) = quarto_markdown_pandoc::readers::qmd::read( + &input_content, + false, + qmd_path.to_str().unwrap(), + &mut output_stream, + true, + None, + ) + .expect("Failed to parse QMD"); + + // Prepare template metadata (adds pagetitle from title) + prepare_template_metadata(&mut pandoc); + + // Load template from embedded resources + // For tests, we'll compile a minimal template inline + let template_source = r#"<!DOCTYPE html> +<html> +<head> +<title>$pagetitle$ + + +$if(title)$ +

$title$

+$endif$ +$body$ + +"#; + + let template = Template::compile(template_source).expect("Failed to compile template"); + + // Convert metadata and render + let writers = HtmlWriters; + render_with_template(&pandoc, &template, &writers).expect("Failed to render") +} + +// Re-implement the minimal functions needed for testing +// (In a real scenario, these would be exposed from the crate) + +use quarto_markdown_pandoc::pandoc::Pandoc; +use quarto_markdown_pandoc::pandoc::meta::{MetaMapEntry, MetaValueWithSourceInfo}; + +fn prepare_template_metadata(pandoc: &mut Pandoc) { + let MetaValueWithSourceInfo::MetaMap { + entries, + source_info, + } = &mut pandoc.meta + else { + return; + }; + + let has_pagetitle = entries.iter().any(|e| e.key == "pagetitle"); + if has_pagetitle { + return; + } + + let title_entry = entries.iter().find(|e| e.key == "title"); + if let Some(entry) = title_entry { + let plain_text = match &entry.value { + MetaValueWithSourceInfo::MetaString { value, .. } => value.clone(), + MetaValueWithSourceInfo::MetaInlines { content, .. } => { + let (text, _) = + quarto_markdown_pandoc::writers::plaintext::inlines_to_string(content); + text + } + MetaValueWithSourceInfo::MetaBlocks { content, .. } => { + let (text, _) = + quarto_markdown_pandoc::writers::plaintext::blocks_to_string(content); + text + } + _ => return, + }; + + entries.push(MetaMapEntry { + key: "pagetitle".to_string(), + key_source: source_info.clone(), + value: MetaValueWithSourceInfo::MetaString { + value: plain_text, + source_info: source_info.clone(), + }, + }); + } +} + +use quarto_doctemplate::{Template, TemplateContext, TemplateValue}; +use quarto_markdown_pandoc::pandoc::block::Block; +use quarto_markdown_pandoc::pandoc::inline::Inlines; +use std::collections::HashMap; + +struct HtmlWriters; + +impl HtmlWriters { + fn write_blocks(&self, blocks: &[Block]) -> anyhow::Result { + let mut buf = Vec::new(); + quarto_markdown_pandoc::writers::html::write_blocks(blocks, &mut buf)?; + Ok(String::from_utf8_lossy(&buf).into_owned()) + } + + fn write_inlines(&self, inlines: &Inlines) -> anyhow::Result { + let mut buf = Vec::new(); + quarto_markdown_pandoc::writers::html::write_inlines(inlines, &mut buf)?; + Ok(String::from_utf8_lossy(&buf).into_owned()) + } +} + +fn meta_to_template_value( + meta: &MetaValueWithSourceInfo, + writers: &HtmlWriters, +) -> anyhow::Result { + Ok(match meta { + MetaValueWithSourceInfo::MetaString { value, .. } => TemplateValue::String(value.clone()), + MetaValueWithSourceInfo::MetaBool { value, .. } => TemplateValue::Bool(*value), + MetaValueWithSourceInfo::MetaInlines { content, .. } => { + TemplateValue::String(writers.write_inlines(content)?) + } + MetaValueWithSourceInfo::MetaBlocks { content, .. } => { + TemplateValue::String(writers.write_blocks(content)?) + } + MetaValueWithSourceInfo::MetaList { items, .. } => { + let values: anyhow::Result> = items + .iter() + .map(|item| meta_to_template_value(item, writers)) + .collect(); + TemplateValue::List(values?) + } + MetaValueWithSourceInfo::MetaMap { entries, .. } => { + let mut map = HashMap::new(); + for entry in entries { + map.insert( + entry.key.clone(), + meta_to_template_value(&entry.value, writers)?, + ); + } + TemplateValue::Map(map) + } + }) +} + +fn render_with_template( + pandoc: &Pandoc, + template: &Template, + writers: &HtmlWriters, +) -> anyhow::Result { + let meta_value = meta_to_template_value(&pandoc.meta, writers)?; + + let mut context = TemplateContext::new(); + if let TemplateValue::Map(map) = meta_value { + for (key, value) in map { + context.insert(key, value); + } + } + + let body = writers.write_blocks(&pandoc.blocks)?; + context.insert("body", TemplateValue::String(body)); + + let output = template + .render(&context) + .map_err(|e| anyhow::anyhow!("Template error: {:?}", e))?; + + Ok(output) +} + +#[test] +fn test_simple_document() { + let html = render_qmd_to_html("simple.qmd"); + + // Check document structure + assert!(html.contains("")); + assert!(html.contains("Simple Test")); + assert!(html.contains("

Simple Test

")); + assert!(html.contains("This is a simple test document.")); +} + +#[test] +fn test_document_with_formatting() { + let html = render_qmd_to_html("with-formatting.qmd"); + + // Check title + assert!(html.contains("Formatting Test")); + + // Check inline formatting + assert!(html.contains("emphasis")); + assert!(html.contains("strong")); + + // Check heading + assert!(html.contains("")); + assert!(html.contains("")); // Empty title + + // Should NOT have title block + assert!(!html.contains("

")); + + // Should have content + assert!(html.contains("Just some content")); +} diff --git a/crates/pico-quarto-render/tests/fixtures/no-title.qmd b/crates/pico-quarto-render/tests/fixtures/no-title.qmd new file mode 100644 index 00000000..126677bf --- /dev/null +++ b/crates/pico-quarto-render/tests/fixtures/no-title.qmd @@ -0,0 +1 @@ +Just some content without a YAML header. diff --git a/crates/pico-quarto-render/tests/fixtures/simple.qmd b/crates/pico-quarto-render/tests/fixtures/simple.qmd new file mode 100644 index 00000000..0353e5a2 --- /dev/null +++ b/crates/pico-quarto-render/tests/fixtures/simple.qmd @@ -0,0 +1,5 @@ +--- +title: "Simple Test" +--- + +This is a simple test document. diff --git a/crates/pico-quarto-render/tests/fixtures/with-formatting.qmd b/crates/pico-quarto-render/tests/fixtures/with-formatting.qmd new file mode 100644 index 00000000..78af4ab6 --- /dev/null +++ b/crates/pico-quarto-render/tests/fixtures/with-formatting.qmd @@ -0,0 +1,9 @@ +--- +title: "Formatting Test" +--- + +This has *emphasis* and **strong** text. + +## A Heading + +A paragraph under the heading. diff --git a/crates/qmd-syntax-helper/src/conversions/definition_lists.rs b/crates/qmd-syntax-helper/src/conversions/definition_lists.rs index 027c693d..6e9e09e0 100644 --- a/crates/qmd-syntax-helper/src/conversions/definition_lists.rs +++ b/crates/qmd-syntax-helper/src/conversions/definition_lists.rs @@ -186,7 +186,9 @@ impl DefinitionListConverter { json::read(&mut json_reader).context("Failed to parse JSON output from pandoc")?; let mut output = Vec::new(); - qmd::write(&pandoc_ast, &mut output).context("Failed to write markdown output")?; + qmd::write(&pandoc_ast, &mut output).map_err(|diagnostics| { + anyhow::anyhow!("Failed to write markdown output: {:?}", diagnostics) + })?; let result = String::from_utf8(output) .context("Failed to parse output as UTF-8")? diff --git a/crates/qmd-syntax-helper/src/conversions/grid_tables.rs b/crates/qmd-syntax-helper/src/conversions/grid_tables.rs index e7b7f7b2..cf080985 100644 --- a/crates/qmd-syntax-helper/src/conversions/grid_tables.rs +++ b/crates/qmd-syntax-helper/src/conversions/grid_tables.rs @@ -133,7 +133,9 @@ impl GridTableConverter { json::read(&mut json_reader).context("Failed to parse JSON output from pandoc")?; let mut output = Vec::new(); - qmd::write(&pandoc_ast, &mut output).context("Failed to write markdown output")?; + qmd::write(&pandoc_ast, &mut output).map_err(|diagnostics| { + anyhow::anyhow!("Failed to write markdown output: {:?}", diagnostics) + })?; let result = String::from_utf8(output) .context("Failed to parse output as UTF-8")? diff --git a/crates/qmd-syntax-helper/src/rule.rs b/crates/qmd-syntax-helper/src/rule.rs index ddc56c79..1cd1efda 100644 --- a/crates/qmd-syntax-helper/src/rule.rs +++ b/crates/qmd-syntax-helper/src/rule.rs @@ -79,9 +79,7 @@ impl RuleRegistry { registry.register(Arc::new( crate::diagnostics::parse_check::ParseChecker::new()?, )); - registry.register(Arc::new( - crate::diagnostics::q_2_30::Q230Checker::new()?, - )); + registry.register(Arc::new(crate::diagnostics::q_2_30::Q230Checker::new()?)); // Register conversion rules registry.register(Arc::new( diff --git a/crates/quarto-doctemplate/Cargo.toml b/crates/quarto-doctemplate/Cargo.toml new file mode 100644 index 00000000..c70c2fbe --- /dev/null +++ b/crates/quarto-doctemplate/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "quarto-doctemplate" +version = "0.1.0" +description = "Pandoc-compatible document template engine for Quarto" +publish = false +authors.workspace = true +categories.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# Tree-sitter grammar for template syntax +tree-sitter-doctemplate = { path = "../tree-sitter-doctemplate" } +tree-sitter = { workspace = true } + +# Generic tree-sitter traversal utilities +quarto-treesitter-ast = { workspace = true } + +# Error reporting infrastructure +quarto-parse-errors = { path = "../quarto-parse-errors" } +quarto-error-reporting = { path = "../quarto-error-reporting" } +quarto-source-map = { path = "../quarto-source-map" } + +# Serialization (for TemplateValue conversion from JSON) +serde = { workspace = true, features = ["derive"] } +serde_json = "1.0" + +# Error handling +thiserror = "1.0" + +[dev-dependencies] +pretty_assertions = "1.4" + +[lints] +workspace = true diff --git a/crates/quarto-doctemplate/src/ast.rs b/crates/quarto-doctemplate/src/ast.rs new file mode 100644 index 00000000..b81de5f9 --- /dev/null +++ b/crates/quarto-doctemplate/src/ast.rs @@ -0,0 +1,211 @@ +/* + * ast.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Template AST types. +//! +//! This module defines the abstract syntax tree for parsed templates. +//! Each node includes source location information for error reporting. + +use quarto_source_map::SourceInfo; + +/// A node in the template AST. +#[derive(Debug, Clone, PartialEq)] +pub enum TemplateNode { + /// Literal text to be output as-is. + Literal(Literal), + + /// Variable interpolation: `$var$` or `$obj.field$` + Variable(VariableRef), + + /// Conditional block: `$if(var)$...$else$...$endif$` + Conditional(Conditional), + + /// For loop: `$for(var)$...$sep$...$endfor$` + ForLoop(ForLoop), + + /// Partial (sub-template): `$partial()$` or `$var:partial()$` + Partial(Partial), + + /// Nesting directive: `$^$` marks indentation point + Nesting(Nesting), + + /// Breakable space block: `$~$...$~$` + BreakableSpace(BreakableSpace), + + /// Comment (not rendered): `$-- comment` + Comment(Comment), +} + +/// Literal text node. +#[derive(Debug, Clone, PartialEq)] +pub struct Literal { + /// The literal text content. + pub text: String, + /// Source location of this literal. + pub source_info: SourceInfo, +} + +/// Conditional block: `$if(var)$...$else$...$endif$` +#[derive(Debug, Clone, PartialEq)] +pub struct Conditional { + /// List of (condition, body) pairs for if/elseif branches. + pub branches: Vec<(VariableRef, Vec)>, + /// Optional else branch. + pub else_branch: Option>, + /// Source location of the entire conditional. + pub source_info: SourceInfo, +} + +/// For loop: `$for(var)$...$sep$...$endfor$` +#[derive(Debug, Clone, PartialEq)] +pub struct ForLoop { + /// Variable to iterate over. + pub var: VariableRef, + /// Loop body. + pub body: Vec, + /// Optional separator between iterations (from `$sep$`). + pub separator: Option>, + /// Source location of the entire loop. + pub source_info: SourceInfo, +} + +/// Partial (sub-template): `$partial()$` or `$var:partial()$` +#[derive(Debug, Clone, PartialEq)] +pub struct Partial { + /// Partial template name. + pub name: String, + /// Optional variable to apply partial to. + pub var: Option, + /// Optional literal separator for array iteration (from `[sep]` syntax). + pub separator: Option, + /// Pipes to apply to partial output. + pub pipes: Vec, + /// Source location of this partial reference. + pub source_info: SourceInfo, + /// Resolved partial template nodes (populated during compilation). + /// + /// This is `None` after parsing and before partial resolution. + /// After `resolve_partials()` is called, this contains the parsed + /// nodes from the partial template file. + pub resolved: Option>, +} + +/// Nesting directive: `$^$` marks indentation point. +#[derive(Debug, Clone, PartialEq)] +pub struct Nesting { + /// Content affected by nesting. + pub children: Vec, + /// Source location of the nesting directive. + pub source_info: SourceInfo, +} + +/// Breakable space block: `$~$...$~$` +#[derive(Debug, Clone, PartialEq)] +pub struct BreakableSpace { + /// Content with breakable spaces. + pub children: Vec, + /// Source location of the breakable space block. + pub source_info: SourceInfo, +} + +/// Comment (not rendered): `$-- comment` +#[derive(Debug, Clone, PartialEq)] +pub struct Comment { + /// The comment text. + pub text: String, + /// Source location of this comment. + pub source_info: SourceInfo, +} + +/// A reference to a variable, possibly with pipes and separator. +#[derive(Debug, Clone, PartialEq)] +pub struct VariableRef { + /// Path components (e.g., `["employee", "salary"]` for `employee.salary`). + pub path: Vec, + /// Pipes to apply to the variable value. + pub pipes: Vec, + /// Optional literal separator for array iteration (from `$var[, ]$` syntax). + /// When present, the variable is iterated as an array with this separator. + pub separator: Option, + /// Source location of this variable reference. + pub source_info: SourceInfo, +} + +impl VariableRef { + /// Create a new variable reference with no pipes or separator. + pub fn new(path: Vec, source_info: SourceInfo) -> Self { + Self { + path, + pipes: Vec::new(), + separator: None, + source_info, + } + } + + /// Create a new variable reference with pipes. + pub fn with_pipes(path: Vec, pipes: Vec, source_info: SourceInfo) -> Self { + Self { + path, + pipes, + separator: None, + source_info, + } + } + + /// Create a new variable reference with separator. + pub fn with_separator( + path: Vec, + pipes: Vec, + separator: String, + source_info: SourceInfo, + ) -> Self { + Self { + path, + pipes, + separator: Some(separator), + source_info, + } + } +} + +/// A pipe transformation applied to a value. +#[derive(Debug, Clone, PartialEq)] +pub struct Pipe { + /// Pipe name (e.g., "uppercase", "left"). + pub name: String, + /// Pipe arguments (for pipes like `left 20 "| "`). + pub args: Vec, + /// Source location of this pipe. + pub source_info: SourceInfo, +} + +impl Pipe { + /// Create a new pipe with no arguments. + pub fn new(name: impl Into, source_info: SourceInfo) -> Self { + Self { + name: name.into(), + args: Vec::new(), + source_info, + } + } + + /// Create a new pipe with arguments. + pub fn with_args(name: impl Into, args: Vec, source_info: SourceInfo) -> Self { + Self { + name: name.into(), + args, + source_info, + } + } +} + +/// An argument to a pipe. +#[derive(Debug, Clone, PartialEq)] +pub enum PipeArg { + /// Integer argument (e.g., width in `left 20`). + Integer(i64), + /// String argument (e.g., border in `left 20 "| "`). + String(String), +} diff --git a/crates/quarto-doctemplate/src/context.rs b/crates/quarto-doctemplate/src/context.rs new file mode 100644 index 00000000..606cb549 --- /dev/null +++ b/crates/quarto-doctemplate/src/context.rs @@ -0,0 +1,269 @@ +/* + * context.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Template value and context types. +//! +//! This module defines the types used to represent template variable values +//! and the context in which templates are evaluated. +//! +//! **Important**: These types are independent of Pandoc AST types. Conversion +//! from Pandoc's `MetaValue` to `TemplateValue` happens in the writer layer. + +use std::collections::HashMap; + +use crate::doc::Doc; + +/// A value that can be used in template evaluation. +/// +/// This mirrors the value types supported by Pandoc's doctemplates library. +#[derive(Debug, Clone, PartialEq)] +pub enum TemplateValue { + /// A string value. + String(String), + + /// A boolean value. + Bool(bool), + + /// A list of values. + List(Vec), + + /// A map of string keys to values. + Map(HashMap), + + /// A null/missing value. + Null, +} + +impl TemplateValue { + /// Check if this value is "truthy" for conditional evaluation. + /// + /// Truthiness rules (matching Pandoc): + /// - Any non-empty map is truthy + /// - Any array containing at least one truthy value is truthy + /// - Any non-empty string is truthy (even "false") + /// - Boolean true is truthy + /// - Everything else is falsy + pub fn is_truthy(&self) -> bool { + match self { + TemplateValue::Bool(b) => *b, + TemplateValue::String(s) => !s.is_empty(), + TemplateValue::List(items) => items.iter().any(|v| v.is_truthy()), + TemplateValue::Map(m) => !m.is_empty(), + TemplateValue::Null => false, + } + } + + /// Get a nested field by path. + /// + /// For example, `get_path(&["employee", "salary"])` on a Map containing + /// `{"employee": {"salary": 50000}}` returns the salary value. + pub fn get_path(&self, path: &[&str]) -> Option<&TemplateValue> { + if path.is_empty() { + return Some(self); + } + + match self { + TemplateValue::Map(m) => { + let first = path[0]; + m.get(first).and_then(|v| v.get_path(&path[1..])) + } + _ => None, + } + } + + /// Render this value as a string for output. + /// + /// - String: returned as-is + /// - Bool: "true" or "" (empty for false) + /// - List: concatenation of rendered elements + /// - Map: "true" + /// - Null: "" + pub fn render(&self) -> String { + match self { + TemplateValue::String(s) => s.clone(), + TemplateValue::Bool(true) => "true".to_string(), + TemplateValue::Bool(false) => String::new(), + TemplateValue::List(items) => items.iter().map(|v| v.render()).collect(), + TemplateValue::Map(_) => "true".to_string(), + TemplateValue::Null => String::new(), + } + } + + /// Convert this value to a Doc for structured output. + /// + /// This is the preferred method for evaluation as it preserves + /// structural information needed for proper nesting. + /// + /// - String: Doc::Text + /// - Bool true: Doc::Text("true") + /// - Bool false: Doc::Empty + /// - List: concatenation of Doc elements + /// - Map: Doc::Text("true") + /// - Null: Doc::Empty + pub fn to_doc(&self) -> Doc { + match self { + TemplateValue::String(s) => Doc::text(s), + TemplateValue::Bool(true) => Doc::text("true"), + TemplateValue::Bool(false) => Doc::Empty, + TemplateValue::List(items) => crate::doc::concat_docs(items.iter().map(|v| v.to_doc())), + TemplateValue::Map(_) => Doc::text("true"), + TemplateValue::Null => Doc::Empty, + } + } + + /// Convert this value to a TemplateContext for partial evaluation. + /// + /// This is used when evaluating applied partials (`$var:partial()$`). + /// The value becomes the context for evaluating the partial template. + /// + /// - Map: the map fields become the context variables + /// - Other values: bound to "it" and also to their own string representation + /// for simple value access + pub fn to_context(&self) -> TemplateContext { + let mut ctx = TemplateContext::new(); + match self { + TemplateValue::Map(m) => { + // Map fields become context variables + for (key, value) in m { + ctx.insert(key.clone(), value.clone()); + } + // Also bind "it" to the entire map for consistency + ctx.insert("it", self.clone()); + } + _ => { + // Non-map values are bound to "it" + ctx.insert("it", self.clone()); + } + } + ctx + } +} + +impl Default for TemplateValue { + fn default() -> Self { + TemplateValue::Null + } +} + +/// A context for template evaluation containing variable bindings. +#[derive(Debug, Clone, Default)] +pub struct TemplateContext { + /// Variable bindings at this level. + variables: HashMap, + + /// Parent context for nested scopes (e.g., inside for loops). + parent: Option>, +} + +impl TemplateContext { + /// Create a new empty context. + pub fn new() -> Self { + Self::default() + } + + /// Insert a variable into the context. + pub fn insert(&mut self, key: impl Into, value: TemplateValue) { + self.variables.insert(key.into(), value); + } + + /// Get a variable from the context, checking parent scopes. + pub fn get(&self, key: &str) -> Option<&TemplateValue> { + self.variables + .get(key) + .or_else(|| self.parent.as_ref().and_then(|p| p.get(key))) + } + + /// Get a variable by path (e.g., "employee.salary"). + pub fn get_path(&self, path: &[&str]) -> Option<&TemplateValue> { + if path.is_empty() { + return None; + } + + self.get(path[0]).and_then(|v| v.get_path(&path[1..])) + } + + /// Create a child context for a nested scope (e.g., for loop iteration). + /// + /// The child context inherits access to parent variables. + pub fn child(&self) -> TemplateContext { + TemplateContext { + variables: HashMap::new(), + parent: Some(Box::new(self.clone())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truthiness() { + assert!(TemplateValue::Bool(true).is_truthy()); + assert!(!TemplateValue::Bool(false).is_truthy()); + + assert!(TemplateValue::String("hello".to_string()).is_truthy()); + assert!(TemplateValue::String("false".to_string()).is_truthy()); // "false" string is truthy! + assert!(!TemplateValue::String("".to_string()).is_truthy()); + + assert!(TemplateValue::List(vec![TemplateValue::Bool(true)]).is_truthy()); + assert!(!TemplateValue::List(vec![TemplateValue::Bool(false)]).is_truthy()); + assert!(!TemplateValue::List(vec![]).is_truthy()); + + let mut map = HashMap::new(); + map.insert("key".to_string(), TemplateValue::Null); + assert!(TemplateValue::Map(map).is_truthy()); // Non-empty map is truthy + + assert!(!TemplateValue::Map(HashMap::new()).is_truthy()); + assert!(!TemplateValue::Null.is_truthy()); + } + + #[test] + fn test_get_path() { + let mut inner = HashMap::new(); + inner.insert( + "salary".to_string(), + TemplateValue::String("50000".to_string()), + ); + + let mut outer = HashMap::new(); + outer.insert("employee".to_string(), TemplateValue::Map(inner)); + + let value = TemplateValue::Map(outer); + + assert_eq!( + value.get_path(&["employee", "salary"]), + Some(&TemplateValue::String("50000".to_string())) + ); + assert_eq!(value.get_path(&["employee", "name"]), None); + assert_eq!(value.get_path(&["nonexistent"]), None); + } + + #[test] + fn test_context_scoping() { + let mut parent = TemplateContext::new(); + parent.insert("x", TemplateValue::String("parent_x".to_string())); + parent.insert("y", TemplateValue::String("parent_y".to_string())); + + let mut child = parent.child(); + child.insert("x", TemplateValue::String("child_x".to_string())); + + // Child shadows parent for 'x' + assert_eq!( + child.get("x"), + Some(&TemplateValue::String("child_x".to_string())) + ); + // Child inherits 'y' from parent + assert_eq!( + child.get("y"), + Some(&TemplateValue::String("parent_y".to_string())) + ); + // Parent unchanged + assert_eq!( + parent.get("x"), + Some(&TemplateValue::String("parent_x".to_string())) + ); + } +} diff --git a/crates/quarto-doctemplate/src/doc.rs b/crates/quarto-doctemplate/src/doc.rs new file mode 100644 index 00000000..2d9d942c --- /dev/null +++ b/crates/quarto-doctemplate/src/doc.rs @@ -0,0 +1,301 @@ +/* + * doc.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Document type for structured template output. +//! +//! This module provides a `Doc` type that represents structured document content, +//! similar to the `Doc` type in Haskell's `doclayout` library. It enables proper +//! handling of nesting (indentation) and breakable spaces. +//! +//! # Why Doc instead of String? +//! +//! Nesting is structural, not post-processing. With String, we'd need to track +//! column position and post-process newlines. With Doc, we build `Prefixed` nodes +//! that the renderer handles correctly. +//! +//! # Minimal Implementation +//! +//! This is a minimal subset of doclayout's 16-variant Doc type. We include only: +//! - `Empty`: nothing +//! - `Text`: literal text +//! - `Concat`: concatenation +//! - `Prefixed`: prefix each line (for nesting) +//! - `BreakingSpace`: space that can break at line wrap +//! - `NewLine`: hard newline + +/// A structured document representation. +/// +/// `Doc` allows us to represent template output in a way that preserves +/// structural information needed for proper nesting and line breaking. +#[derive(Debug, Clone, PartialEq)] +pub enum Doc { + /// Empty document (produces no output). + Empty, + + /// Literal text. + Text(String), + + /// Concatenation of two documents. + Concat(Box, Box), + + /// Prefix each line of the inner document with the given string. + /// Used for implementing nesting/indentation. + Prefixed(String, Box), + + /// A space that can break at line wrap boundaries. + /// Without line wrapping, renders as a single space. + BreakingSpace, + + /// A hard newline. + NewLine, +} + +impl Doc { + /// Create a text document from a string. + pub fn text(s: impl Into) -> Self { + let s = s.into(); + if s.is_empty() { + Doc::Empty + } else { + Doc::Text(s) + } + } + + /// Concatenate two documents. + /// + /// This is smart about Empty documents - concatenating with Empty + /// returns the other document unchanged. + pub fn concat(self, other: Doc) -> Self { + match (&self, &other) { + (Doc::Empty, _) => other, + (_, Doc::Empty) => self, + _ => Doc::Concat(Box::new(self), Box::new(other)), + } + } + + /// Check if this document is empty. + pub fn is_empty(&self) -> bool { + match self { + Doc::Empty => true, + Doc::Text(s) => s.is_empty(), + Doc::Concat(a, b) => a.is_empty() && b.is_empty(), + Doc::Prefixed(_, inner) => inner.is_empty(), + Doc::BreakingSpace => false, + Doc::NewLine => false, + } + } + + /// Apply a prefix to each line of this document (for nesting). + pub fn prefixed(prefix: impl Into, inner: Doc) -> Self { + let prefix = prefix.into(); + if inner.is_empty() { + Doc::Empty + } else { + Doc::Prefixed(prefix, Box::new(inner)) + } + } + + /// Create a document from a newline. + pub fn newline() -> Self { + Doc::NewLine + } + + /// Create a breaking space. + pub fn breaking_space() -> Self { + Doc::BreakingSpace + } + + /// Render this document to a string. + /// + /// # Arguments + /// * `line_width` - Optional maximum line width for reflowing. + /// If None, no reflowing is performed. + /// + /// # Note + /// The current implementation ignores `line_width` and does not + /// perform reflowing. This may be added in a future version. + pub fn render(&self, _line_width: Option) -> String { + self.render_simple() + } + + /// Render without any line width constraints. + fn render_simple(&self) -> String { + match self { + Doc::Empty => String::new(), + Doc::Text(s) => s.clone(), + Doc::Concat(a, b) => { + let mut result = a.render_simple(); + result.push_str(&b.render_simple()); + result + } + Doc::Prefixed(prefix, inner) => { + let inner_str = inner.render_simple(); + apply_prefix(&inner_str, prefix) + } + Doc::BreakingSpace => " ".to_string(), + Doc::NewLine => "\n".to_string(), + } + } +} + +/// Apply a prefix to each line after the first. +/// +/// The first line is not prefixed (it continues from the current position). +/// All subsequent lines get the prefix prepended. +fn apply_prefix(s: &str, prefix: &str) -> String { + let lines: Vec<&str> = s.split('\n').collect(); + if lines.len() <= 1 { + return s.to_string(); + } + + let mut result = String::new(); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + result.push('\n'); + result.push_str(prefix); + } + result.push_str(line); + } + result +} + +impl Default for Doc { + fn default() -> Self { + Doc::Empty + } +} + +/// Concatenate multiple documents. +pub fn concat_docs(docs: impl IntoIterator) -> Doc { + docs.into_iter() + .fold(Doc::Empty, |acc, doc| acc.concat(doc)) +} + +/// Intersperse documents with a separator. +pub fn intersperse_docs(docs: Vec, sep: Doc) -> Doc { + let mut result = Doc::Empty; + let mut first = true; + + for doc in docs { + if doc.is_empty() { + continue; + } + if first { + first = false; + } else { + result = result.concat(sep.clone()); + } + result = result.concat(doc); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty() { + assert_eq!(Doc::Empty.render(None), ""); + assert!(Doc::Empty.is_empty()); + } + + #[test] + fn test_text() { + assert_eq!(Doc::text("hello").render(None), "hello"); + assert!(!Doc::text("hello").is_empty()); + + // Empty string becomes Empty + assert!(Doc::text("").is_empty()); + } + + #[test] + fn test_concat() { + let doc = Doc::text("hello").concat(Doc::text(" world")); + assert_eq!(doc.render(None), "hello world"); + + // Concat with Empty is identity + assert_eq!(Doc::text("hello").concat(Doc::Empty).render(None), "hello"); + assert_eq!(Doc::Empty.concat(Doc::text("hello")).render(None), "hello"); + } + + #[test] + fn test_newline() { + let doc = Doc::text("line1") + .concat(Doc::newline()) + .concat(Doc::text("line2")); + assert_eq!(doc.render(None), "line1\nline2"); + } + + #[test] + fn test_breaking_space() { + let doc = Doc::text("hello") + .concat(Doc::breaking_space()) + .concat(Doc::text("world")); + // Without reflow, breaking space is just a space + assert_eq!(doc.render(None), "hello world"); + } + + #[test] + fn test_prefixed_single_line() { + // Single line - no prefix applied + let doc = Doc::prefixed(" ", Doc::text("hello")); + assert_eq!(doc.render(None), "hello"); + } + + #[test] + fn test_prefixed_multiline() { + // Multiline - prefix applied to lines after first + let inner = Doc::text("line1") + .concat(Doc::newline()) + .concat(Doc::text("line2")) + .concat(Doc::newline()) + .concat(Doc::text("line3")); + let doc = Doc::prefixed(" ", inner); + assert_eq!(doc.render(None), "line1\n line2\n line3"); + } + + #[test] + fn test_prefixed_empty() { + // Prefixed empty is empty + let doc = Doc::prefixed(" ", Doc::Empty); + assert!(doc.is_empty()); + } + + #[test] + fn test_concat_docs() { + let docs = vec![Doc::text("a"), Doc::text("b"), Doc::text("c")]; + assert_eq!(concat_docs(docs).render(None), "abc"); + } + + #[test] + fn test_intersperse_docs() { + let docs = vec![Doc::text("a"), Doc::text("b"), Doc::text("c")]; + let sep = Doc::text(", "); + assert_eq!(intersperse_docs(docs, sep).render(None), "a, b, c"); + } + + #[test] + fn test_intersperse_with_empty() { + // Empty docs are skipped + let docs = vec![Doc::text("a"), Doc::Empty, Doc::text("c")]; + let sep = Doc::text(", "); + assert_eq!(intersperse_docs(docs, sep).render(None), "a, c"); + } + + #[test] + fn test_nested_prefixed() { + // Nested prefixes should accumulate + let inner = Doc::text("line1") + .concat(Doc::newline()) + .concat(Doc::text("line2")); + let middle = Doc::prefixed(" ", inner); + let outer = Doc::prefixed("> ", middle); + + // First line has no prefix, second line gets both prefixes + assert_eq!(outer.render(None), "line1\n> line2"); + } +} diff --git a/crates/quarto-doctemplate/src/error.rs b/crates/quarto-doctemplate/src/error.rs new file mode 100644 index 00000000..bffebef3 --- /dev/null +++ b/crates/quarto-doctemplate/src/error.rs @@ -0,0 +1,46 @@ +/* + * error.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Error types for template parsing and evaluation. + +use thiserror::Error; + +/// Errors that can occur during template operations. +#[derive(Debug, Error)] +pub enum TemplateError { + /// Error parsing the template syntax. + #[error("Parse error: {message}")] + ParseError { + message: String, + // TODO: Add source location when we integrate with quarto-parse-errors + }, + + /// Error evaluating the template. + #[error("Evaluation error: {message}")] + EvaluationError { message: String }, + + /// Error loading a partial template. + #[error("Partial not found: {name}")] + PartialNotFound { name: String }, + + /// Recursive partial inclusion detected. + #[error("Recursive partial inclusion detected (depth > {max_depth}): {name}")] + RecursivePartial { name: String, max_depth: usize }, + + /// Unknown pipe name. + #[error("Unknown pipe: {name}")] + UnknownPipe { name: String }, + + /// Invalid pipe arguments. + #[error("Invalid arguments for pipe '{pipe}': {message}")] + InvalidPipeArgs { pipe: String, message: String }, + + /// I/O error (e.g., reading partial file). + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +/// Result type for template operations. +pub type TemplateResult = Result; diff --git a/crates/quarto-doctemplate/src/eval_context.rs b/crates/quarto-doctemplate/src/eval_context.rs new file mode 100644 index 00000000..1d135cce --- /dev/null +++ b/crates/quarto-doctemplate/src/eval_context.rs @@ -0,0 +1,405 @@ +/* + * eval_context.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Evaluation context for template rendering. +//! +//! This module provides [`EvalContext`], which is threaded through all evaluation +//! functions to support: +//! +//! 1. **Diagnostics**: Collect errors and warnings with source locations +//! 2. **State tracking**: Partial nesting depth for recursion protection +//! 3. **Configuration**: Strict mode for treating warnings as errors + +use crate::context::TemplateContext; +use quarto_error_reporting::{DiagnosticKind, DiagnosticMessage, DiagnosticMessageBuilder}; +use quarto_source_map::SourceInfo; + +/// Collector for diagnostic messages during template evaluation. +/// +/// This is a simplified version of the DiagnosticCollector from quarto-markdown-pandoc, +/// tailored for template evaluation. +#[derive(Debug, Default)] +pub struct DiagnosticCollector { + diagnostics: Vec, +} + +impl DiagnosticCollector { + /// Create a new empty diagnostic collector. + pub fn new() -> Self { + Self { + diagnostics: Vec::new(), + } + } + + /// Add a diagnostic message. + pub fn add(&mut self, diagnostic: DiagnosticMessage) { + self.diagnostics.push(diagnostic); + } + + /// Add an error message with source location. + pub fn error_at(&mut self, message: impl Into, location: SourceInfo) { + let diagnostic = DiagnosticMessageBuilder::error(message) + .with_location(location) + .build(); + self.add(diagnostic); + } + + /// Add a warning message with source location. + pub fn warn_at(&mut self, message: impl Into, location: SourceInfo) { + let diagnostic = DiagnosticMessageBuilder::warning(message) + .with_location(location) + .build(); + self.add(diagnostic); + } + + /// Add an error message with error code and source location. + pub fn error_with_code( + &mut self, + code: &str, + message: impl Into, + location: SourceInfo, + ) { + let diagnostic = DiagnosticMessageBuilder::error(message) + .with_code(code) + .with_location(location) + .build(); + self.add(diagnostic); + } + + /// Add a warning message with error code and source location. + pub fn warn_with_code(&mut self, code: &str, message: impl Into, location: SourceInfo) { + let diagnostic = DiagnosticMessageBuilder::warning(message) + .with_code(code) + .with_location(location) + .build(); + self.add(diagnostic); + } + + /// Check if any errors were collected (warnings don't count). + pub fn has_errors(&self) -> bool { + self.diagnostics + .iter() + .any(|d| d.kind == DiagnosticKind::Error) + } + + /// Get a reference to the collected diagnostics. + pub fn diagnostics(&self) -> &[DiagnosticMessage] { + &self.diagnostics + } + + /// Consume the collector and return the diagnostics, sorted by source location. + pub fn into_diagnostics(mut self) -> Vec { + self.diagnostics.sort_by_key(|diag| { + diag.location + .as_ref() + .map(|loc| loc.start_offset()) + .unwrap_or(0) + }); + self.diagnostics + } + + /// Check if the collector is empty. + pub fn is_empty(&self) -> bool { + self.diagnostics.is_empty() + } +} + +/// Context for template evaluation. +/// +/// This struct is threaded through all evaluation functions to: +/// 1. Collect diagnostics (errors and warnings) with source locations +/// 2. Track evaluation state (e.g., partial nesting depth) +/// 3. Provide access to the variable context +pub struct EvalContext<'a> { + /// Variable bindings for template interpolation. + pub variables: &'a TemplateContext, + + /// Diagnostic collector for errors and warnings. + pub diagnostics: DiagnosticCollector, + + /// Current partial nesting depth (for recursion protection). + pub partial_depth: usize, + + /// Maximum partial nesting depth before error. + pub max_partial_depth: usize, + + /// Strict mode: treat warnings (e.g., undefined variables) as errors. + pub strict_mode: bool, +} + +impl<'a> EvalContext<'a> { + /// Create a new evaluation context with the given variable bindings. + pub fn new(variables: &'a TemplateContext) -> Self { + Self { + variables, + diagnostics: DiagnosticCollector::new(), + partial_depth: 0, + max_partial_depth: 50, + strict_mode: false, + } + } + + /// Enable or disable strict mode. + /// + /// In strict mode, warnings (like undefined variables) are treated as errors. + pub fn with_strict_mode(mut self, strict: bool) -> Self { + self.strict_mode = strict; + self + } + + /// Set the maximum partial nesting depth. + pub fn with_max_partial_depth(mut self, depth: usize) -> Self { + self.max_partial_depth = depth; + self + } + + /// Create a child context for nested evaluation (e.g., for loops). + /// + /// The child context has fresh diagnostics but inherits configuration + /// like strict_mode and max_partial_depth. + pub fn child(&self, child_variables: &'a TemplateContext) -> EvalContext<'a> { + EvalContext { + variables: child_variables, + diagnostics: DiagnosticCollector::new(), + partial_depth: self.partial_depth, + max_partial_depth: self.max_partial_depth, + strict_mode: self.strict_mode, + } + } + + /// Merge diagnostics from a child context into this context. + pub fn merge_diagnostics(&mut self, child: EvalContext) { + for diag in child.diagnostics.into_diagnostics() { + self.diagnostics.add(diag); + } + } + + /// Add an error with source location. + pub fn error_at(&mut self, message: impl Into, location: &SourceInfo) { + self.diagnostics.error_at(message, location.clone()); + } + + /// Add a warning with source location. + pub fn warn_at(&mut self, message: impl Into, location: &SourceInfo) { + self.diagnostics.warn_at(message, location.clone()); + } + + /// Add an error or warning depending on strict mode. + /// + /// In strict mode, this adds an error. Otherwise, it adds a warning. + pub fn warn_or_error_at(&mut self, message: impl Into, location: &SourceInfo) { + if self.strict_mode { + self.error_at(message, location); + } else { + self.warn_at(message, location); + } + } + + /// Add an error with error code and source location. + pub fn error_with_code( + &mut self, + code: &str, + message: impl Into, + location: &SourceInfo, + ) { + self.diagnostics + .error_with_code(code, message, location.clone()); + } + + /// Add a warning with error code and source location. + pub fn warn_with_code( + &mut self, + code: &str, + message: impl Into, + location: &SourceInfo, + ) { + self.diagnostics + .warn_with_code(code, message, location.clone()); + } + + /// Add an error or warning with error code depending on strict mode. + /// + /// In strict mode, this adds an error. Otherwise, it adds a warning. + pub fn warn_or_error_with_code( + &mut self, + code: &str, + message: impl Into, + location: &SourceInfo, + ) { + if self.strict_mode { + self.error_with_code(code, message, location); + } else { + self.warn_with_code(code, message, location); + } + } + + /// Add a structured diagnostic message. + pub fn add_diagnostic(&mut self, diagnostic: DiagnosticMessage) { + self.diagnostics.add(diagnostic); + } + + /// Check if any errors have been collected. + pub fn has_errors(&self) -> bool { + self.diagnostics.has_errors() + } + + /// Consume the context and return collected diagnostics. + pub fn into_diagnostics(self) -> Vec { + self.diagnostics.into_diagnostics() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_diagnostic_collector_new() { + let collector = DiagnosticCollector::new(); + assert!(collector.is_empty()); + assert!(!collector.has_errors()); + } + + #[test] + fn test_diagnostic_collector_error() { + let mut collector = DiagnosticCollector::new(); + let location = SourceInfo::default(); + collector.error_at("Test error", location); + + assert!(!collector.is_empty()); + assert!(collector.has_errors()); + assert_eq!(collector.diagnostics().len(), 1); + } + + #[test] + fn test_diagnostic_collector_warning() { + let mut collector = DiagnosticCollector::new(); + let location = SourceInfo::default(); + collector.warn_at("Test warning", location); + + assert!(!collector.is_empty()); + assert!(!collector.has_errors()); // Warnings don't count as errors + assert_eq!(collector.diagnostics().len(), 1); + } + + #[test] + fn test_eval_context_new() { + let vars = TemplateContext::new(); + let ctx = EvalContext::new(&vars); + + assert!(!ctx.strict_mode); + assert_eq!(ctx.partial_depth, 0); + assert_eq!(ctx.max_partial_depth, 50); + assert!(!ctx.has_errors()); + } + + #[test] + fn test_eval_context_strict_mode() { + let vars = TemplateContext::new(); + let ctx = EvalContext::new(&vars).with_strict_mode(true); + + assert!(ctx.strict_mode); + } + + #[test] + fn test_eval_context_warn_or_error() { + let vars = TemplateContext::new(); + let location = SourceInfo::default(); + + // Normal mode: warning + let mut ctx = EvalContext::new(&vars); + ctx.warn_or_error_at("Test", &location); + assert!(!ctx.has_errors()); + + // Strict mode: error + let mut ctx_strict = EvalContext::new(&vars).with_strict_mode(true); + ctx_strict.warn_or_error_at("Test", &location); + assert!(ctx_strict.has_errors()); + } + + #[test] + fn test_eval_context_child() { + let vars = TemplateContext::new(); + let ctx = EvalContext::new(&vars) + .with_strict_mode(true) + .with_max_partial_depth(25); + + let child_vars = TemplateContext::new(); + let child = ctx.child(&child_vars); + + // Child inherits configuration + assert!(child.strict_mode); + assert_eq!(child.max_partial_depth, 25); + // Child has fresh diagnostics + assert!(!child.has_errors()); + } + + #[test] + fn test_eval_context_merge_diagnostics() { + let vars = TemplateContext::new(); + let location = SourceInfo::default(); + + let mut parent = EvalContext::new(&vars); + parent.warn_at("Parent warning", &location); + + let child_vars = TemplateContext::new(); + let mut child = parent.child(&child_vars); + child.error_at("Child error", &location); + + parent.merge_diagnostics(child); + + assert!(parent.has_errors()); + let diagnostics = parent.into_diagnostics(); + assert_eq!(diagnostics.len(), 2); + } + + #[test] + fn test_diagnostic_collector_error_with_code() { + let mut collector = DiagnosticCollector::new(); + let location = SourceInfo::default(); + collector.error_with_code("Q-10-2", "Undefined variable: foo", location); + + assert!(!collector.is_empty()); + assert!(collector.has_errors()); + assert_eq!(collector.diagnostics().len(), 1); + assert_eq!(collector.diagnostics()[0].code.as_deref(), Some("Q-10-2")); + } + + #[test] + fn test_diagnostic_collector_warn_with_code() { + let mut collector = DiagnosticCollector::new(); + let location = SourceInfo::default(); + collector.warn_with_code("Q-10-2", "Undefined variable: foo", location); + + assert!(!collector.is_empty()); + assert!(!collector.has_errors()); // Warnings don't count as errors + assert_eq!(collector.diagnostics().len(), 1); + assert_eq!(collector.diagnostics()[0].code.as_deref(), Some("Q-10-2")); + } + + #[test] + fn test_eval_context_warn_or_error_with_code() { + let vars = TemplateContext::new(); + let location = SourceInfo::default(); + + // Normal mode: warning with code + let mut ctx = EvalContext::new(&vars); + ctx.warn_or_error_with_code("Q-10-2", "Test", &location); + assert!(!ctx.has_errors()); + let diagnostics = ctx.into_diagnostics(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].code.as_deref(), Some("Q-10-2")); + assert_eq!(diagnostics[0].kind, DiagnosticKind::Warning); + + // Strict mode: error with code + let mut ctx_strict = EvalContext::new(&vars).with_strict_mode(true); + ctx_strict.warn_or_error_with_code("Q-10-2", "Test", &location); + assert!(ctx_strict.has_errors()); + let diagnostics = ctx_strict.into_diagnostics(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].code.as_deref(), Some("Q-10-2")); + assert_eq!(diagnostics[0].kind, DiagnosticKind::Error); + } +} diff --git a/crates/quarto-doctemplate/src/evaluator.rs b/crates/quarto-doctemplate/src/evaluator.rs new file mode 100644 index 00000000..29a9d39f --- /dev/null +++ b/crates/quarto-doctemplate/src/evaluator.rs @@ -0,0 +1,947 @@ +/* + * evaluator.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Template evaluation engine. +//! +//! This module implements the evaluation of parsed templates against a context. +//! The evaluator produces a `Doc` tree that can be rendered to a string. + +use crate::ast::TemplateNode; +use crate::ast::VariableRef; +use crate::ast::{BreakableSpace, Comment, Conditional, ForLoop, Literal, Nesting, Partial}; +use crate::context::{TemplateContext, TemplateValue}; +use crate::doc::{Doc, concat_docs, intersperse_docs}; +use crate::error::TemplateResult; +use crate::eval_context::EvalContext; +use crate::parser::Template; +use quarto_error_reporting::DiagnosticMessage; + +impl Template { + /// Render this template with the given context. + /// + /// # Arguments + /// * `context` - The variable context for evaluation + /// + /// # Returns + /// The rendered output string, or an error if evaluation fails. + /// + /// Note: This method does not report warnings. Use [`render_with_diagnostics`] + /// if you need access to warnings (like undefined variable warnings). + pub fn render(&self, context: &TemplateContext) -> TemplateResult { + let mut eval_ctx = EvalContext::new(context); + let doc = evaluate_nodes(&self.nodes, &mut eval_ctx)?; + Ok(doc.render(None)) + } + + /// Evaluate this template to a Doc tree. + /// + /// This is useful when you need the structured representation + /// for further processing before final string rendering. + /// + /// Note: This method does not report warnings. Use [`evaluate_with_diagnostics`] + /// if you need access to warnings. + pub fn evaluate(&self, context: &TemplateContext) -> TemplateResult { + let mut eval_ctx = EvalContext::new(context); + evaluate_nodes(&self.nodes, &mut eval_ctx) + } + + /// Render this template with diagnostics collection. + /// + /// Returns both the rendered output and any diagnostics (errors and warnings) + /// that were collected during evaluation. + /// + /// # Arguments + /// * `context` - The variable context for evaluation + /// + /// # Returns + /// A tuple of (result, diagnostics) where: + /// - `result` is `Ok(String)` if rendering succeeded, `Err(())` if there were errors + /// - `diagnostics` is a list of all errors and warnings + /// + /// # Example + /// + /// ```ignore + /// let template = Template::compile("Hello, $name$!")?; + /// let ctx = TemplateContext::new(); // Note: 'name' not defined + /// + /// let (result, diagnostics) = template.render_with_diagnostics(&ctx); + /// + /// // Result is Ok because undefined variables are warnings, not errors + /// assert!(result.is_ok()); + /// // But we get a warning about the undefined variable + /// assert!(!diagnostics.is_empty()); + /// ``` + pub fn render_with_diagnostics( + &self, + context: &TemplateContext, + ) -> (Result, Vec) { + let mut eval_ctx = EvalContext::new(context); + let result = evaluate_nodes(&self.nodes, &mut eval_ctx); + + let diagnostics = eval_ctx.into_diagnostics(); + let has_errors = diagnostics + .iter() + .any(|d| d.kind == quarto_error_reporting::DiagnosticKind::Error); + + match result { + Ok(doc) if !has_errors => (Ok(doc.render(None)), diagnostics), + _ => (Err(()), diagnostics), + } + } + + /// Render this template in strict mode. + /// + /// In strict mode, warnings (like undefined variables) are treated as errors. + /// + /// # Arguments + /// * `context` - The variable context for evaluation + /// + /// # Returns + /// A tuple of (result, diagnostics). + pub fn render_strict( + &self, + context: &TemplateContext, + ) -> (Result, Vec) { + let mut eval_ctx = EvalContext::new(context).with_strict_mode(true); + let result = evaluate_nodes(&self.nodes, &mut eval_ctx); + + let diagnostics = eval_ctx.into_diagnostics(); + let has_errors = diagnostics + .iter() + .any(|d| d.kind == quarto_error_reporting::DiagnosticKind::Error); + + match result { + Ok(doc) if !has_errors => (Ok(doc.render(None)), diagnostics), + _ => (Err(()), diagnostics), + } + } + + /// Evaluate this template to a Doc tree with diagnostics collection. + /// + /// Similar to [`evaluate`], but also returns collected diagnostics. + pub fn evaluate_with_diagnostics( + &self, + context: &TemplateContext, + ) -> (TemplateResult, Vec) { + let mut eval_ctx = EvalContext::new(context); + let result = evaluate_nodes(&self.nodes, &mut eval_ctx); + let diagnostics = eval_ctx.into_diagnostics(); + (result, diagnostics) + } +} + +/// Evaluate a list of template nodes to a Doc. +/// +/// This is the internal evaluation function that threads EvalContext. +fn evaluate_nodes(nodes: &[TemplateNode], ctx: &mut EvalContext) -> TemplateResult { + let docs: Result, _> = nodes.iter().map(|n| evaluate_node(n, ctx)).collect(); + Ok(concat_docs(docs?)) +} + +/// Evaluate a single template node to a Doc. +fn evaluate_node(node: &TemplateNode, ctx: &mut EvalContext) -> TemplateResult { + match node { + TemplateNode::Literal(Literal { text, .. }) => Ok(Doc::text(text)), + + TemplateNode::Variable(var) => Ok(render_variable(var, ctx)), + + TemplateNode::Conditional(Conditional { + branches, + else_branch, + .. + }) => evaluate_conditional(branches, else_branch, ctx), + + TemplateNode::ForLoop(ForLoop { + var, + body, + separator, + .. + }) => evaluate_for_loop(var, body, separator, ctx), + + TemplateNode::Partial(partial) => evaluate_partial(partial, ctx), + + TemplateNode::Nesting(Nesting { children, .. }) => { + // TODO: Implement nesting/indentation tracking + // For now, just evaluate children without nesting + evaluate_nodes(children, ctx) + } + + TemplateNode::BreakableSpace(BreakableSpace { children, .. }) => { + // For now, breakable spaces just evaluate their children + // Full breakable space semantics require line-width-aware rendering + evaluate_nodes(children, ctx) + } + + TemplateNode::Comment(Comment { .. }) => { + // Comments produce no output + Ok(Doc::Empty) + } + } +} + +/// Resolve a variable reference in the context. +fn resolve_variable<'a>( + var: &VariableRef, + variables: &'a TemplateContext, +) -> Option<&'a TemplateValue> { + // Variable paths may contain dots (e.g., "employee.salary" is a single path element) + // Split on dots to get the actual path components + let path: Vec<&str> = var.path.iter().flat_map(|s| s.split('.')).collect(); + variables.get_path(&path) +} + +/// Render a variable reference to a Doc. +fn render_variable(var: &VariableRef, ctx: &mut EvalContext) -> Doc { + match resolve_variable(var, ctx.variables) { + Some(value) => { + // Handle literal separator for arrays: $var[, ]$ + if let Some(sep) = &var.separator { + if let TemplateValue::List(items) = value { + let docs: Vec = items.iter().map(|v| v.to_doc()).collect(); + return intersperse_docs(docs, Doc::text(sep)); + } + } + // TODO: Apply pipes + value.to_doc() + } + None => { + // Emit warning or error depending on strict mode + let var_path = var.path.join("."); + ctx.warn_or_error_with_code( + "Q-10-2", + format!("Undefined variable: {}", var_path), + &var.source_info, + ); + Doc::Empty + } + } +} + +/// Evaluate a conditional block. +fn evaluate_conditional( + branches: &[(VariableRef, Vec)], + else_branch: &Option>, + ctx: &mut EvalContext, +) -> TemplateResult { + // Try each if/elseif branch + for (condition, body) in branches { + if let Some(value) = resolve_variable(condition, ctx.variables) { + if value.is_truthy() { + return evaluate_nodes(body, ctx); + } + } + } + + // No branch matched, try else + if let Some(else_body) = else_branch { + evaluate_nodes(else_body, ctx) + } else { + Ok(Doc::Empty) + } +} + +/// Evaluate a for loop. +fn evaluate_for_loop( + var: &VariableRef, + body: &[TemplateNode], + separator: &Option>, + ctx: &mut EvalContext, +) -> TemplateResult { + let value = resolve_variable(var, ctx.variables); + + // Determine what to iterate over + let items: Vec<&TemplateValue> = match value { + Some(TemplateValue::List(items)) => items.iter().collect(), + Some(TemplateValue::Map(_)) => vec![value.unwrap()], // Single iteration over map + Some(v) if v.is_truthy() => vec![v], // Single iteration for truthy scalars + _ => vec![], // No iterations for null/falsy + }; + + if items.is_empty() { + return Ok(Doc::Empty); + } + + // Get the variable name for binding (use the last path component) + let var_name = var.path.last().map(|s| s.as_str()).unwrap_or(""); + + // Render separator if present + let sep_doc = if let Some(sep_nodes) = separator { + Some(evaluate_nodes(sep_nodes, ctx)?) + } else { + None + }; + + // Render each iteration + let mut results = Vec::new(); + for item in &items { + let mut child_vars = ctx.variables.child(); + + // Bind to variable name AND "it" (Pandoc semantics) + child_vars.insert(var_name, (*item).clone()); + child_vars.insert("it", (*item).clone()); + + // Create child context and evaluate + let mut child_ctx = ctx.child(&child_vars); + let result = evaluate_nodes(body, &mut child_ctx)?; + results.push(result); + + // Merge any diagnostics from the child context + ctx.merge_diagnostics(child_ctx); + } + + // Join with separator + match sep_doc { + Some(sep) => Ok(intersperse_docs(results, sep)), + None => Ok(concat_docs(results)), + } +} + +/// Evaluate a partial template. +/// +/// Partials come in two forms: +/// - Bare partial: `$partial()$` - evaluated with current context +/// - Applied partial: `$var:partial()$` - evaluated with var's value as context +/// +/// For applied partials with array values, the partial is evaluated once per item, +/// with optional separator between iterations. +fn evaluate_partial(partial: &Partial, ctx: &mut EvalContext) -> TemplateResult { + let Partial { + name, + var, + separator, + pipes, + resolved, + source_info, + } = partial; + + // Get the resolved partial nodes + let nodes = match resolved { + Some(nodes) => nodes, + None => { + // Partial was not resolved during compilation - emit error + ctx.error_with_code( + "Q-10-5", + format!("Partial '{}' was not resolved", name), + source_info, + ); + return Ok(Doc::Empty); + } + }; + + // TODO: Apply pipes to partial output + let _ = pipes; + + match var { + None => { + // Bare partial: evaluate with current context + evaluate_nodes(nodes, ctx) + } + Some(var_ref) => { + // Applied partial: evaluate with var's value as context + let value = resolve_variable(var_ref, ctx.variables); + + match value { + None => { + // Variable not found - emit warning/error + let var_path = var_ref.path.join("."); + ctx.warn_or_error_with_code( + "Q-10-2", + format!("Undefined variable: {}", var_path), + &var_ref.source_info, + ); + Ok(Doc::Empty) + } + Some(TemplateValue::List(items)) => { + // Iterate over list items + let mut results = Vec::new(); + for item in items { + let item_ctx = item.to_context(); + let mut child_ctx = ctx.child(&item_ctx); + let result = evaluate_nodes(nodes, &mut child_ctx)?; + results.push(result); + ctx.merge_diagnostics(child_ctx); + } + + // Join with separator + if let Some(sep) = separator { + Ok(intersperse_docs(results, Doc::text(sep))) + } else { + Ok(concat_docs(results)) + } + } + Some(value) => { + // Single value: evaluate once with value as context + let item_ctx = value.to_context(); + let mut child_ctx = ctx.child(&item_ctx); + let result = evaluate_nodes(nodes, &mut child_ctx)?; + ctx.merge_diagnostics(child_ctx); + Ok(result) + } + } + } + } +} + +// Re-export the old evaluate function for backwards compatibility +// (kept as a module-level function in case anyone was using it) + +/// Evaluate a list of template nodes to a Doc. +/// +/// This is a convenience function that creates a temporary EvalContext. +/// For production use with diagnostics, use `Template::render_with_diagnostics`. +pub fn evaluate(nodes: &[TemplateNode], context: &TemplateContext) -> TemplateResult { + let mut eval_ctx = EvalContext::new(context); + evaluate_nodes(nodes, &mut eval_ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn compile(source: &str) -> Template { + Template::compile(source).expect("template should parse") + } + + fn ctx() -> TemplateContext { + TemplateContext::new() + } + + #[test] + fn test_literal_text() { + let template = compile("Hello, world!"); + assert_eq!(template.render(&ctx()).unwrap(), "Hello, world!"); + } + + #[test] + fn test_simple_variable() { + let template = compile("Hello, $name$!"); + let mut ctx = ctx(); + ctx.insert("name", TemplateValue::String("Alice".to_string())); + assert_eq!(template.render(&ctx).unwrap(), "Hello, Alice!"); + } + + #[test] + fn test_missing_variable() { + let template = compile("Hello, $name$!"); + // Variable not defined - should produce empty string + assert_eq!(template.render(&ctx()).unwrap(), "Hello, !"); + } + + #[test] + fn test_missing_variable_warning() { + let template = compile("Hello, $name$!"); + let (result, diagnostics) = template.render_with_diagnostics(&ctx()); + + // Should succeed (undefined variables are warnings, not errors) + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "Hello, !"); + + // Should have a warning with error code Q-10-2 + assert_eq!(diagnostics.len(), 1); + assert_eq!( + diagnostics[0].kind, + quarto_error_reporting::DiagnosticKind::Warning + ); + assert!(diagnostics[0].title.contains("Undefined variable")); + assert_eq!(diagnostics[0].code.as_deref(), Some("Q-10-2")); + } + + #[test] + fn test_missing_variable_strict_mode() { + let template = compile("Hello, $name$!"); + let (result, diagnostics) = template.render_strict(&ctx()); + + // Should fail in strict mode + assert!(result.is_err()); + + // Should have an error (not a warning) with error code Q-10-2 + assert_eq!(diagnostics.len(), 1); + assert_eq!( + diagnostics[0].kind, + quarto_error_reporting::DiagnosticKind::Error + ); + assert_eq!(diagnostics[0].code.as_deref(), Some("Q-10-2")); + } + + #[test] + fn test_nested_variable() { + let template = compile("Salary: $employee.salary$"); + let mut ctx = ctx(); + + let mut employee = HashMap::new(); + employee.insert( + "salary".to_string(), + TemplateValue::String("50000".to_string()), + ); + ctx.insert("employee", TemplateValue::Map(employee)); + + assert_eq!(template.render(&ctx).unwrap(), "Salary: 50000"); + } + + #[test] + fn test_boolean_true() { + let template = compile("Value: $flag$"); + let mut ctx = ctx(); + ctx.insert("flag", TemplateValue::Bool(true)); + assert_eq!(template.render(&ctx).unwrap(), "Value: true"); + } + + #[test] + fn test_boolean_false() { + let template = compile("Value: $flag$"); + let mut ctx = ctx(); + ctx.insert("flag", TemplateValue::Bool(false)); + // false renders as empty + assert_eq!(template.render(&ctx).unwrap(), "Value: "); + } + + #[test] + fn test_list_concatenation() { + let template = compile("Items: $items$"); + let mut ctx = ctx(); + ctx.insert( + "items", + TemplateValue::List(vec![ + TemplateValue::String("a".to_string()), + TemplateValue::String("b".to_string()), + TemplateValue::String("c".to_string()), + ]), + ); + assert_eq!(template.render(&ctx).unwrap(), "Items: abc"); + } + + #[test] + fn test_list_with_separator() { + let template = compile("Items: $items[, ]$"); + let mut ctx = ctx(); + ctx.insert( + "items", + TemplateValue::List(vec![ + TemplateValue::String("a".to_string()), + TemplateValue::String("b".to_string()), + TemplateValue::String("c".to_string()), + ]), + ); + assert_eq!(template.render(&ctx).unwrap(), "Items: a, b, c"); + } + + #[test] + fn test_conditional_true() { + let template = compile("$if(show)$visible$endif$"); + let mut ctx = ctx(); + ctx.insert("show", TemplateValue::Bool(true)); + assert_eq!(template.render(&ctx).unwrap(), "visible"); + } + + #[test] + fn test_conditional_false() { + let template = compile("$if(show)$visible$endif$"); + let mut ctx = ctx(); + ctx.insert("show", TemplateValue::Bool(false)); + assert_eq!(template.render(&ctx).unwrap(), ""); + } + + #[test] + fn test_conditional_missing() { + let template = compile("$if(show)$visible$endif$"); + // Variable not defined + assert_eq!(template.render(&ctx()).unwrap(), ""); + } + + #[test] + fn test_conditional_else() { + let template = compile("$if(show)$yes$else$no$endif$"); + let mut ctx = ctx(); + ctx.insert("show", TemplateValue::Bool(false)); + assert_eq!(template.render(&ctx).unwrap(), "no"); + } + + #[test] + fn test_conditional_elseif() { + // Note: The tree-sitter grammar currently has issues with elseif/else parsing + // when they appear without whitespace. Use braces syntax or whitespace for now. + // TODO: Fix this by implementing an external scanner in tree-sitter + + // Using brace syntax which parses correctly + let template = compile("${if(a)}A${elseif(b)}B${else}C${endif}"); + + // a is true + let mut ctx1 = ctx(); + ctx1.insert("a", TemplateValue::Bool(true)); + assert_eq!(template.render(&ctx1).unwrap(), "A"); + + // a false, b true + let mut ctx2 = ctx(); + ctx2.insert("a", TemplateValue::Bool(false)); + ctx2.insert("b", TemplateValue::Bool(true)); + assert_eq!(template.render(&ctx2).unwrap(), "B"); + + // both false + let mut ctx3 = ctx(); + ctx3.insert("a", TemplateValue::Bool(false)); + ctx3.insert("b", TemplateValue::Bool(false)); + assert_eq!(template.render(&ctx3).unwrap(), "C"); + } + + #[test] + fn test_for_loop_basic() { + let template = compile("$for(x)$$x$$endfor$"); + let mut ctx = ctx(); + ctx.insert( + "x", + TemplateValue::List(vec![ + TemplateValue::String("a".to_string()), + TemplateValue::String("b".to_string()), + TemplateValue::String("c".to_string()), + ]), + ); + assert_eq!(template.render(&ctx).unwrap(), "abc"); + } + + #[test] + fn test_for_loop_with_separator() { + let template = compile("$for(x)$$x$$sep$, $endfor$"); + let mut ctx = ctx(); + ctx.insert( + "x", + TemplateValue::List(vec![ + TemplateValue::String("a".to_string()), + TemplateValue::String("b".to_string()), + TemplateValue::String("c".to_string()), + ]), + ); + assert_eq!(template.render(&ctx).unwrap(), "a, b, c"); + } + + #[test] + fn test_for_loop_with_it() { + // "it" should be bound to current iteration value + let template = compile("$for(x)$$it$$endfor$"); + let mut ctx = ctx(); + ctx.insert( + "x", + TemplateValue::List(vec![ + TemplateValue::String("1".to_string()), + TemplateValue::String("2".to_string()), + ]), + ); + assert_eq!(template.render(&ctx).unwrap(), "12"); + } + + #[test] + fn test_for_loop_empty() { + let template = compile("$for(x)$item$endfor$"); + let mut ctx = ctx(); + ctx.insert("x", TemplateValue::List(vec![])); + assert_eq!(template.render(&ctx).unwrap(), ""); + } + + #[test] + fn test_for_loop_single_value() { + // Non-list truthy value should iterate once + let template = compile("$for(x)$[$x$]$endfor$"); + let mut ctx = ctx(); + ctx.insert("x", TemplateValue::String("single".to_string())); + assert_eq!(template.render(&ctx).unwrap(), "[single]"); + } + + #[test] + fn test_comment() { + // Comment ends at newline; newline needs to be + // chomped by comment because it's otherwise unavoidable + let template = compile("before$-- this is a comment\nafter"); + assert_eq!(template.render(&ctx()).unwrap(), "beforeafter"); + } + + #[test] + fn test_escaped_dollar() { + let template = compile("Price: $$100"); + assert_eq!(template.render(&ctx()).unwrap(), "Price: $100"); + } + + #[test] + fn test_combined() { + let template = compile("$if(items)$Items: $for(items)$$it$$sep$, $endfor$$endif$"); + let mut ctx = ctx(); + ctx.insert( + "items", + TemplateValue::List(vec![ + TemplateValue::String("foo".to_string()), + TemplateValue::String("bar".to_string()), + ]), + ); + assert_eq!(template.render(&ctx).unwrap(), "Items: foo, bar"); + } + + #[test] + fn test_map_truthiness() { + let template = compile("$if(data)$has data$endif$"); + let mut ctx = ctx(); + let mut data = HashMap::new(); + data.insert("key".to_string(), TemplateValue::Null); + ctx.insert("data", TemplateValue::Map(data)); + // Non-empty map is truthy + assert_eq!(template.render(&ctx).unwrap(), "has data"); + } + + #[test] + fn test_string_false_is_truthy() { + // The string "false" is truthy (only empty string is falsy) + let template = compile("$if(x)$truthy$endif$"); + let mut ctx = ctx(); + ctx.insert("x", TemplateValue::String("false".to_string())); + assert_eq!(template.render(&ctx).unwrap(), "truthy"); + } + + #[test] + fn test_multiple_undefined_variables() { + let template = compile("$a$ $b$ $c$"); + let (result, diagnostics) = template.render_with_diagnostics(&ctx()); + + // Should succeed with warnings + assert!(result.is_ok()); + assert_eq!(result.unwrap(), " "); // Three empties with spaces between + + // Should have three warnings + assert_eq!(diagnostics.len(), 3); + for diag in &diagnostics { + assert_eq!(diag.kind, quarto_error_reporting::DiagnosticKind::Warning); + } + } + + #[test] + fn test_for_loop_with_undefined_in_body() { + // Undefined variable inside a for loop body + let template = compile("$for(x)$[$y$]$endfor$"); + let mut ctx = ctx(); + ctx.insert( + "x", + TemplateValue::List(vec![ + TemplateValue::String("a".to_string()), + TemplateValue::String("b".to_string()), + ]), + ); + + let (result, diagnostics) = template.render_with_diagnostics(&ctx); + + // Should succeed (warnings, not errors) + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "[][]"); // Two empty brackets + + // Should have two warnings (one per iteration) + assert_eq!(diagnostics.len(), 2); + } + + // Partial tests using MemoryResolver for in-memory partials + + use crate::resolver::MemoryResolver; + use std::path::Path; + + fn compile_with_partials( + source: &str, + partials: impl IntoIterator, + ) -> Template { + let resolver = MemoryResolver::with_partials(partials.into_iter()); + Template::compile_with_resolver(source, Path::new("test.html"), &resolver, 0) + .expect("template should compile") + } + + #[test] + fn test_bare_partial() { + // Bare partial: $header()$ evaluates with current context + let template = compile_with_partials("$header()$", [("header", "

$title$

")]); + let mut ctx = ctx(); + ctx.insert("title", TemplateValue::String("Hello".to_string())); + + assert_eq!(template.render(&ctx).unwrap(), "

Hello

"); + } + + #[test] + fn test_bare_partial_nested() { + // Nested bare partials + let template = compile_with_partials( + "$wrapper()$", + [ + ("wrapper", "
$inner()$
"), + ("inner", "Content: $text$"), + ], + ); + let mut ctx = ctx(); + ctx.insert("text", TemplateValue::String("Hello".to_string())); + + assert_eq!(template.render(&ctx).unwrap(), "
Content: Hello
"); + } + + #[test] + fn test_applied_partial_with_map() { + // Applied partial: $item:card()$ evaluates with item as context + let template = + compile_with_partials("$item:card()$", [("card", "
$name$ - $price$
")]); + + let mut ctx = ctx(); + let mut item = HashMap::new(); + item.insert( + "name".to_string(), + TemplateValue::String("Widget".to_string()), + ); + item.insert( + "price".to_string(), + TemplateValue::String("$10".to_string()), + ); + ctx.insert("item", TemplateValue::Map(item)); + + assert_eq!(template.render(&ctx).unwrap(), "
Widget - $10
"); + } + + #[test] + fn test_applied_partial_with_list() { + // Applied partial with list: iterates over items + let template = compile_with_partials("$items:item()$", [("item", "[$name$]")]); + + let mut ctx = ctx(); + let items = vec![ + { + let mut m = HashMap::new(); + m.insert("name".to_string(), TemplateValue::String("A".to_string())); + TemplateValue::Map(m) + }, + { + let mut m = HashMap::new(); + m.insert("name".to_string(), TemplateValue::String("B".to_string())); + TemplateValue::Map(m) + }, + ]; + ctx.insert("items", TemplateValue::List(items)); + + assert_eq!(template.render(&ctx).unwrap(), "[A][B]"); + } + + #[test] + fn test_applied_partial_with_list_and_separator() { + // Applied partial with list and separator: $items:item()[, ]$ + let template = compile_with_partials("$items:item()[, ]$", [("item", "$name$")]); + + let mut ctx = ctx(); + let items = vec![ + { + let mut m = HashMap::new(); + m.insert("name".to_string(), TemplateValue::String("A".to_string())); + TemplateValue::Map(m) + }, + { + let mut m = HashMap::new(); + m.insert("name".to_string(), TemplateValue::String("B".to_string())); + TemplateValue::Map(m) + }, + { + let mut m = HashMap::new(); + m.insert("name".to_string(), TemplateValue::String("C".to_string())); + TemplateValue::Map(m) + }, + ]; + ctx.insert("items", TemplateValue::List(items)); + + assert_eq!(template.render(&ctx).unwrap(), "A, B, C"); + } + + #[test] + fn test_applied_partial_with_scalar() { + // Applied partial with scalar value: binds to "it" + let template = compile_with_partials("$name:bold()$", [("bold", "$it$")]); + + let mut ctx = ctx(); + ctx.insert("name", TemplateValue::String("Alice".to_string())); + + assert_eq!(template.render(&ctx).unwrap(), "Alice"); + } + + #[test] + fn test_partial_missing_variable_warning() { + // Undefined variable in applied partial should emit warning + let template = compile_with_partials("$x:partial()$", [("partial", "content")]); + + let (result, diagnostics) = template.render_with_diagnostics(&ctx()); + + // Should succeed (warning, not error) + assert!(result.is_ok()); + assert_eq!(result.unwrap(), ""); // Empty because x is undefined + + // Should have a warning about undefined variable + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].title.contains("Undefined variable")); + } + + #[test] + fn test_unresolved_partial_error() { + // Partial that wasn't resolved during compilation should emit error + // We can't easily test this with the normal API, but we can test + // the diagnostic behavior indirectly + let template = compile_with_partials("Text only", []); + assert_eq!(template.render(&ctx()).unwrap(), "Text only"); + } + + #[test] + fn test_partial_in_conditional() { + // Partial inside conditional block + let template = + compile_with_partials("$if(show)$$header()$$endif$", [("header", "[HEADER]")]); + + let mut ctx_true = ctx(); + ctx_true.insert("show", TemplateValue::Bool(true)); + assert_eq!(template.render(&ctx_true).unwrap(), "[HEADER]"); + + let mut ctx_false = ctx(); + ctx_false.insert("show", TemplateValue::Bool(false)); + assert_eq!(template.render(&ctx_false).unwrap(), ""); + } + + #[test] + fn test_partial_in_for_loop() { + // Partial inside for loop + let template = + compile_with_partials("$for(items)$$item()$$sep$, $endfor$", [("item", "[$it$]")]); + + let mut ctx = ctx(); + ctx.insert( + "items", + TemplateValue::List(vec![ + TemplateValue::String("a".to_string()), + TemplateValue::String("b".to_string()), + ]), + ); + + assert_eq!(template.render(&ctx).unwrap(), "[a], [b]"); + } + + #[test] + fn test_to_context_map() { + // TemplateValue::to_context with map + let mut map = HashMap::new(); + map.insert("x".to_string(), TemplateValue::String("val".to_string())); + let value = TemplateValue::Map(map); + + let ctx = value.to_context(); + assert_eq!( + ctx.get("x"), + Some(&TemplateValue::String("val".to_string())) + ); + // Also has "it" bound to the whole map + assert!(ctx.get("it").is_some()); + } + + #[test] + fn test_to_context_scalar() { + // TemplateValue::to_context with scalar + let value = TemplateValue::String("hello".to_string()); + let ctx = value.to_context(); + + // Scalar is bound to "it" + assert_eq!( + ctx.get("it"), + Some(&TemplateValue::String("hello".to_string())) + ); + } +} diff --git a/crates/quarto-doctemplate/src/lib.rs b/crates/quarto-doctemplate/src/lib.rs new file mode 100644 index 00000000..cd88a030 --- /dev/null +++ b/crates/quarto-doctemplate/src/lib.rs @@ -0,0 +1,63 @@ +/* + * lib.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Pandoc-compatible document template engine for Quarto. +//! +//! This crate provides a template engine that is compatible with Pandoc's +//! [doctemplates](https://github.com/jgm/doctemplates) library. It supports: +//! +//! - Variable interpolation: `$variable$` or `${variable}` +//! - Nested field access: `$employee.salary$` +//! - Conditionals: `$if(var)$...$else$...$endif$` +//! - For loops: `$for(items)$...$sep$...$endfor$` +//! - Partials: `$partial()$` or `$var:partial()$` +//! - Pipes: `$var/uppercase$`, `$var/left 20 "" ""$` +//! - Nesting directive: `$^$` for indentation control +//! - Breakable spaces: `$~$...$~$` +//! - Comments: `$-- comment` +//! +//! # Architecture +//! +//! The template engine is **independent of Pandoc AST types**. It defines its own +//! [`TemplateValue`] and [`TemplateContext`] types. Conversion from Pandoc's +//! `MetaValue` to `TemplateValue` happens in the writer layer (not in this crate). +//! +//! # Example +//! +//! ```ignore +//! use quarto_doctemplate::{Template, TemplateContext, TemplateValue}; +//! +//! // Parse a template +//! let template = Template::compile("Hello, $name$!")?; +//! +//! // Create a context with variables +//! let mut ctx = TemplateContext::new(); +//! ctx.insert("name", TemplateValue::String("World".to_string())); +//! +//! // Render the template +//! let output = template.render(&ctx)?; +//! assert_eq!(output, "Hello, World!"); +//! ``` + +pub mod ast; +pub mod context; +pub mod doc; +pub mod error; +pub mod eval_context; +pub mod evaluator; +pub mod parser; +pub mod resolver; + +// Re-export main types at crate root +pub use ast::{ + BreakableSpace, Comment, Conditional, ForLoop, Literal, Nesting, Partial, Pipe, PipeArg, + TemplateNode, VariableRef, +}; +pub use context::{TemplateContext, TemplateValue}; +pub use doc::Doc; +pub use error::TemplateError; +pub use eval_context::{DiagnosticCollector, EvalContext}; +pub use parser::Template; +pub use resolver::{FileSystemResolver, MemoryResolver, NullResolver, PartialResolver}; diff --git a/crates/quarto-doctemplate/src/parser.rs b/crates/quarto-doctemplate/src/parser.rs new file mode 100644 index 00000000..c1b0ca3c --- /dev/null +++ b/crates/quarto-doctemplate/src/parser.rs @@ -0,0 +1,937 @@ +/* + * parser.rs + * Copyright (c) 2025 Posit, PBC + */ + +//! Template parser using tree-sitter. +//! +//! This module converts tree-sitter parse trees into the template AST. +//! It uses the generic traversal utilities from `quarto-treesitter-ast`. + +use crate::ast::{ + BreakableSpace, Comment, Conditional, ForLoop, Literal, Nesting, Partial, Pipe, PipeArg, + TemplateNode, VariableRef, +}; +use crate::error::{TemplateError, TemplateResult}; +use crate::resolver::{PartialResolver, remove_final_newline, resolve_partial_path}; +use quarto_source_map::{FileId, SourceContext, SourceInfo}; +use quarto_treesitter_ast::bottomup_traverse_concrete_tree; +use std::path::Path; +use tree_sitter::{Node, Parser}; + +/// A compiled template ready for evaluation. +#[derive(Debug, Clone)] +pub struct Template { + /// The parsed template AST. + pub(crate) nodes: Vec, + + /// Original source (for error reporting). + #[allow(dead_code)] + pub(crate) source: String, +} + +/// Parser context passed through the bottom-up traversal. +#[derive(Debug)] +pub struct ParserContext { + /// Source context for tracking locations. + pub source_context: SourceContext, + /// The current file ID. + pub file_id: FileId, +} + +impl ParserContext { + /// Create a new parser context for a file. + pub fn new(filename: &str) -> Self { + let mut source_context = SourceContext::new(); + let file_id = source_context.add_file(filename.to_string(), None); + Self { + source_context, + file_id, + } + } + + /// Create source info from a tree-sitter node. + fn source_info_from_node(&self, node: &Node) -> SourceInfo { + let range = quarto_source_map::Range { + start: quarto_source_map::Location { + offset: node.start_byte(), + row: node.start_position().row, + column: node.start_position().column, + }, + end: quarto_source_map::Location { + offset: node.end_byte(), + row: node.end_position().row, + column: node.end_position().column, + }, + }; + SourceInfo::from_range(self.file_id, range) + } +} + +/// Intermediate representation during bottom-up traversal. +/// Each node kind produces one of these, which gets accumulated +/// as we traverse up the tree. +#[derive(Debug)] +enum Intermediate { + /// Final template nodes (from template_element) + Nodes(Vec), + /// A single template node + Node(TemplateNode), + /// A variable reference (used in conditionals and loops) + VarRef(VariableRef), + /// A pipe transformation + Pipe(Pipe), + /// Literal text (for intermediate values like partial names, pipe args) + Text(String), + /// A partial reference (name only, source info is reconstructed from outer node) + Partial(String), + /// A bare partial reference: $partial()$ with optional pipes + BarePartial(String, Vec, SourceInfo), + /// Content for conditional branches + ConditionalThen(Vec), + ConditionalElse(Vec), + ConditionalElseIf(VariableRef, Vec), + /// Content for loops + LoopContent(Vec), + LoopSeparator(Vec), + LoopVariable(String, SourceInfo), + /// Literal separator for partials/variables + LiteralSeparator(String), + /// Unknown/marker node (ignored in processing) + Unknown, +} + +impl Template { + /// Compile a template from source text. + /// + /// # Arguments + /// * `source` - The template source text + /// + /// # Returns + /// A compiled template, or an error if parsing fails. + pub fn compile(source: &str) -> TemplateResult { + Self::compile_with_filename(source, "