From a350374a3ed232031534af9819788ba8a42176b9 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 25 Nov 2025 17:15:08 -0600 Subject: [PATCH 1/3] 2025 updates - doctemplate port, grammar and error message fixes --- crates/pico-quarto-render/Cargo.toml | 6 + crates/pico-quarto-render/README.md | 27 + crates/pico-quarto-render/profile.json.gz | Bin 0 -> 58989 bytes .../src/embedded_resolver.rs | 88 + .../pico-quarto-render/src/format_writers.rs | 110 + crates/pico-quarto-render/src/main.rs | 119 +- .../src/resources/html-template/html.styles | 209 + .../src/resources/html-template/html.template | 70 + .../src/resources/html-template/metadata.html | 23 + .../html-template/styles.citations.html | 21 + .../src/resources/html-template/styles.html | 213 + .../src/resources/html-template/template.html | 95 + .../resources/html-template/title-block.html | 19 + .../src/resources/html-template/toc.html | 6 + .../src/template_context.rs | 364 + crates/pico-quarto-render/tests/end_to_end.rs | 230 + .../tests/fixtures/no-title.qmd | 1 + .../tests/fixtures/simple.qmd | 5 + .../tests/fixtures/with-formatting.qmd | 9 + .../src/conversions/definition_lists.rs | 4 +- .../src/conversions/grid_tables.rs | 4 +- crates/qmd-syntax-helper/src/rule.rs | 4 +- crates/quarto-doctemplate/Cargo.toml | 38 + crates/quarto-doctemplate/src/ast.rs | 211 + crates/quarto-doctemplate/src/context.rs | 269 + crates/quarto-doctemplate/src/doc.rs | 301 + crates/quarto-doctemplate/src/error.rs | 46 + crates/quarto-doctemplate/src/eval_context.rs | 405 + crates/quarto-doctemplate/src/evaluator.rs | 947 + crates/quarto-doctemplate/src/lib.rs | 63 + crates/quarto-doctemplate/src/parser.rs | 937 + crates/quarto-doctemplate/src/resolver.rs | 225 + .../test-fixtures/author-card.template | 1 + .../test-fixtures/conditional.template | 1 + .../test-fixtures/forloop.template | 1 + .../test-fixtures/main.template | 51 + .../test-fixtures/simple.template | 1 + .../test-fixtures/with-partial.template | 3 + .../tests/integration_tests.rs | 154 + .../CONTRIBUTING-ERRORS.md | 453 + crates/quarto-error-reporting/README.md | 419 +- .../quarto-error-reporting/error_catalog.json | 183 + .../examples/basic_error.rs | 23 + .../examples/builder_api.rs | 65 + .../examples/custom_rendering.rs | 91 + .../examples/diagnostic_collector.rs | 138 + .../examples/migration_helpers.rs | 70 + .../examples/with_error_code.rs | 65 + .../examples/with_location.rs | 64 + crates/quarto-error-reporting/src/builder.rs | 24 +- crates/quarto-error-reporting/src/catalog.rs | 6 +- .../quarto-error-reporting/src/diagnostic.rs | 78 +- crates/quarto-error-reporting/src/macros.rs | 61 +- crates/quarto-markdown-pandoc/Cargo.toml | 4 +- .../snapshots/error-corpus/json/001.snap | 2 +- .../snapshots/error-corpus/json/005.snap | 6 +- .../snapshots/error-corpus/json/007.snap | 18 +- .../snapshots/error-corpus/json/009.snap | 16 + .../snapshots/error-corpus/text/001.snap | 2 +- .../snapshots/error-corpus/text/005.snap | 8 +- .../snapshots/error-corpus/text/007.snap | 8 +- .../snapshots/error-corpus/text/009.snap | 6 +- .../snapshots/json/math-with-attr.snap | 2 +- crates/quarto-markdown-pandoc/src/main.rs | 49 +- .../quarto-markdown-pandoc/src/pandoc/attr.rs | 200 + .../src/pandoc/location.rs | 85 +- .../quarto-markdown-pandoc/src/pandoc/meta.rs | 22 +- .../src/pandoc/treesitter.rs | 7 +- .../src/pandoc/treesitter_utils/paragraph.rs | 4 + .../pandoc/treesitter_utils/postprocess.rs | 9 +- .../src/readers/json.rs | 202 +- .../quarto-markdown-pandoc/src/readers/qmd.rs | 9 +- .../src/readers/qmd_error_message_table.rs | 80 +- .../src/readers/qmd_error_messages.rs | 613 +- .../quarto-markdown-pandoc/src/traversals.rs | 115 +- .../src/utils/diagnostic_collector.rs | 8 +- .../quarto-markdown-pandoc/src/utils/mod.rs | 5 +- .../src/utils/trim_source_location.rs | 281 + .../src/writers/ansi.rs | 209 +- .../src/writers/html.rs | 4 +- .../src/writers/json.rs | 911 +- .../quarto-markdown-pandoc/src/writers/mod.rs | 1 + .../src/writers/native.rs | 185 +- .../src/writers/plaintext.rs | 580 + .../quarto-markdown-pandoc/src/writers/qmd.rs | 556 +- .../tests/error_node_analysis.rs | 5 +- ...ted_emphasis_asterisk_strong_with_emph.qmd | 1 + .../qmd-json-qmd/nested_emphasis_context.qmd | 5 + .../nested_emphasis_emph_with_strong.qmd | 1 + .../nested_emphasis_triple_nested.qmd | 1 + ...d_emphasis_underscore_strong_with_emph.qmd | 1 + crates/quarto-markdown-pandoc/tests/test.rs | 16 +- .../tests/test_math_attr.rs | 315 + crates/quarto-parse-errors/Cargo.toml | 22 + crates/quarto-parse-errors/README.md | 150 + .../error-message-macros/Cargo.toml | 14 + .../error-message-macros/src/lib.rs | 196 + .../scripts/build_error_table.ts | 340 + .../src/error_generation.rs | 701 + crates/quarto-parse-errors/src/error_table.rs | 94 + crates/quarto-parse-errors/src/lib.rs | 132 + .../src/tree_sitter_log.rs | 396 + crates/quarto-treesitter-ast/Cargo.toml | 9 + crates/quarto-treesitter-ast/src/lib.rs | 23 + .../quarto-treesitter-ast/src/traversals.rs | 206 + crates/tree-sitter-doctemplate/Cargo.toml | 32 + crates/tree-sitter-doctemplate/build.rs | 27 + .../grammar/.editorconfig | 46 + .../grammar/.gitattributes | 42 + .../grammar/.gitignore | 50 + .../grammar/CMakeLists.txt | 66 + .../grammar/Cargo.toml | 34 + .../tree-sitter-doctemplate/grammar/Makefile | 99 + .../grammar/Package.swift | 41 + .../grammar/binding.gyp | 35 + .../bindings/c/tree-sitter-doctemplate.pc.in | 10 + .../c/tree_sitter/tree-sitter-doctemplate.h | 16 + .../grammar/bindings/go/binding.go | 15 + .../grammar/bindings/go/binding_test.go | 15 + .../grammar/bindings/node/binding.cc | 19 + .../grammar/bindings/node/binding_test.js | 9 + .../grammar/bindings/node/index.d.ts | 27 + .../grammar/bindings/node/index.js | 11 + .../bindings/python/tests/test_binding.py | 12 + .../tree_sitter_doctemplate/__init__.py | 42 + .../tree_sitter_doctemplate/__init__.pyi | 10 + .../python/tree_sitter_doctemplate/binding.c | 35 + .../python/tree_sitter_doctemplate/py.typed | 0 .../grammar/bindings/rust/build.rs | 21 + .../grammar/bindings/rust/lib.rs | 51 + .../swift/TreeSitterDoctemplate/doctemplate.h | 16 + .../TreeSitterDoctemplateTests.swift | 12 + crates/tree-sitter-doctemplate/grammar/go.mod | 5 + .../grammar/grammar.js | 160 + .../grammar/package.json | 52 + .../grammar/pyproject.toml | 29 + .../tree-sitter-doctemplate/grammar/setup.py | 77 + .../grammar/src/grammar.json | 1488 + .../grammar/src/node-types.json | 589 + .../grammar/src/parser.c | 62075 ++++++++++++++++ .../grammar/src/scanner.c | 240 + .../grammar/src/tree_sitter/alloc.h | 54 + .../grammar/src/tree_sitter/array.h | 291 + .../grammar/src/tree_sitter/parser.h | 286 + .../grammar/test/corpus/template.txt | 205 + .../grammar/tree-sitter.json | 39 + crates/tree-sitter-doctemplate/src/lib.rs | 38 + .../test-templates/author-card.template | 1 + .../test-templates/html/html.styles | 209 + .../test-templates/html/html.template | 70 + .../test-templates/html/metadata.html | 23 + .../test-templates/html/styles.html | 213 + .../test-templates/html/template.html | 95 + .../test-templates/html/title-block.html | 19 + .../test-templates/html/toc.html | 6 + .../test-templates/main.template | 51 + .../test-templates/test.md | 21 + .../tree-sitter-qmd/bindings/rust/parser.rs | 32 + .../tree-sitter-markdown/src/scanner.c | 12 +- .../test/corpus/new-spec.txt | 147 +- 160 files changed, 79734 insertions(+), 1442 deletions(-) create mode 100644 crates/pico-quarto-render/README.md create mode 100644 crates/pico-quarto-render/profile.json.gz create mode 100644 crates/pico-quarto-render/src/embedded_resolver.rs create mode 100644 crates/pico-quarto-render/src/format_writers.rs create mode 100644 crates/pico-quarto-render/src/resources/html-template/html.styles create mode 100644 crates/pico-quarto-render/src/resources/html-template/html.template create mode 100644 crates/pico-quarto-render/src/resources/html-template/metadata.html create mode 100644 crates/pico-quarto-render/src/resources/html-template/styles.citations.html create mode 100644 crates/pico-quarto-render/src/resources/html-template/styles.html create mode 100644 crates/pico-quarto-render/src/resources/html-template/template.html create mode 100644 crates/pico-quarto-render/src/resources/html-template/title-block.html create mode 100644 crates/pico-quarto-render/src/resources/html-template/toc.html create mode 100644 crates/pico-quarto-render/src/template_context.rs create mode 100644 crates/pico-quarto-render/tests/end_to_end.rs create mode 100644 crates/pico-quarto-render/tests/fixtures/no-title.qmd create mode 100644 crates/pico-quarto-render/tests/fixtures/simple.qmd create mode 100644 crates/pico-quarto-render/tests/fixtures/with-formatting.qmd create mode 100644 crates/quarto-doctemplate/Cargo.toml create mode 100644 crates/quarto-doctemplate/src/ast.rs create mode 100644 crates/quarto-doctemplate/src/context.rs create mode 100644 crates/quarto-doctemplate/src/doc.rs create mode 100644 crates/quarto-doctemplate/src/error.rs create mode 100644 crates/quarto-doctemplate/src/eval_context.rs create mode 100644 crates/quarto-doctemplate/src/evaluator.rs create mode 100644 crates/quarto-doctemplate/src/lib.rs create mode 100644 crates/quarto-doctemplate/src/parser.rs create mode 100644 crates/quarto-doctemplate/src/resolver.rs create mode 100644 crates/quarto-doctemplate/test-fixtures/author-card.template create mode 100644 crates/quarto-doctemplate/test-fixtures/conditional.template create mode 100644 crates/quarto-doctemplate/test-fixtures/forloop.template create mode 100644 crates/quarto-doctemplate/test-fixtures/main.template create mode 100644 crates/quarto-doctemplate/test-fixtures/simple.template create mode 100644 crates/quarto-doctemplate/test-fixtures/with-partial.template create mode 100644 crates/quarto-doctemplate/tests/integration_tests.rs create mode 100644 crates/quarto-error-reporting/CONTRIBUTING-ERRORS.md create mode 100644 crates/quarto-error-reporting/examples/basic_error.rs create mode 100644 crates/quarto-error-reporting/examples/builder_api.rs create mode 100644 crates/quarto-error-reporting/examples/custom_rendering.rs create mode 100644 crates/quarto-error-reporting/examples/diagnostic_collector.rs create mode 100644 crates/quarto-error-reporting/examples/migration_helpers.rs create mode 100644 crates/quarto-error-reporting/examples/with_error_code.rs create mode 100644 crates/quarto-error-reporting/examples/with_location.rs create mode 100644 crates/quarto-markdown-pandoc/src/utils/trim_source_location.rs create mode 100644 crates/quarto-markdown-pandoc/src/writers/plaintext.rs create mode 100644 crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/nested_emphasis_asterisk_strong_with_emph.qmd create mode 100644 crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/nested_emphasis_context.qmd create mode 100644 crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/nested_emphasis_emph_with_strong.qmd create mode 100644 crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/nested_emphasis_triple_nested.qmd create mode 100644 crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/nested_emphasis_underscore_strong_with_emph.qmd create mode 100644 crates/quarto-markdown-pandoc/tests/test_math_attr.rs create mode 100644 crates/quarto-parse-errors/Cargo.toml create mode 100644 crates/quarto-parse-errors/README.md create mode 100644 crates/quarto-parse-errors/error-message-macros/Cargo.toml create mode 100644 crates/quarto-parse-errors/error-message-macros/src/lib.rs create mode 100755 crates/quarto-parse-errors/scripts/build_error_table.ts create mode 100644 crates/quarto-parse-errors/src/error_generation.rs create mode 100644 crates/quarto-parse-errors/src/error_table.rs create mode 100644 crates/quarto-parse-errors/src/lib.rs create mode 100644 crates/quarto-parse-errors/src/tree_sitter_log.rs create mode 100644 crates/quarto-treesitter-ast/Cargo.toml create mode 100644 crates/quarto-treesitter-ast/src/lib.rs create mode 100644 crates/quarto-treesitter-ast/src/traversals.rs create mode 100644 crates/tree-sitter-doctemplate/Cargo.toml create mode 100644 crates/tree-sitter-doctemplate/build.rs create mode 100644 crates/tree-sitter-doctemplate/grammar/.editorconfig create mode 100644 crates/tree-sitter-doctemplate/grammar/.gitattributes create mode 100644 crates/tree-sitter-doctemplate/grammar/.gitignore create mode 100644 crates/tree-sitter-doctemplate/grammar/CMakeLists.txt create mode 100644 crates/tree-sitter-doctemplate/grammar/Cargo.toml create mode 100644 crates/tree-sitter-doctemplate/grammar/Makefile create mode 100644 crates/tree-sitter-doctemplate/grammar/Package.swift create mode 100644 crates/tree-sitter-doctemplate/grammar/binding.gyp create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/c/tree-sitter-doctemplate.pc.in create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/c/tree_sitter/tree-sitter-doctemplate.h create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/go/binding.go create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/go/binding_test.go create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/node/binding.cc create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/node/binding_test.js create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/node/index.d.ts create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/node/index.js create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/python/tests/test_binding.py create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/python/tree_sitter_doctemplate/__init__.py create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/python/tree_sitter_doctemplate/__init__.pyi create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/python/tree_sitter_doctemplate/binding.c create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/python/tree_sitter_doctemplate/py.typed create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/rust/build.rs create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/rust/lib.rs create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/swift/TreeSitterDoctemplate/doctemplate.h create mode 100644 crates/tree-sitter-doctemplate/grammar/bindings/swift/TreeSitterDoctemplateTests/TreeSitterDoctemplateTests.swift create mode 100644 crates/tree-sitter-doctemplate/grammar/go.mod create mode 100644 crates/tree-sitter-doctemplate/grammar/grammar.js create mode 100644 crates/tree-sitter-doctemplate/grammar/package.json create mode 100644 crates/tree-sitter-doctemplate/grammar/pyproject.toml create mode 100644 crates/tree-sitter-doctemplate/grammar/setup.py create mode 100644 crates/tree-sitter-doctemplate/grammar/src/grammar.json create mode 100644 crates/tree-sitter-doctemplate/grammar/src/node-types.json create mode 100644 crates/tree-sitter-doctemplate/grammar/src/parser.c create mode 100644 crates/tree-sitter-doctemplate/grammar/src/scanner.c create mode 100644 crates/tree-sitter-doctemplate/grammar/src/tree_sitter/alloc.h create mode 100644 crates/tree-sitter-doctemplate/grammar/src/tree_sitter/array.h create mode 100644 crates/tree-sitter-doctemplate/grammar/src/tree_sitter/parser.h create mode 100644 crates/tree-sitter-doctemplate/grammar/test/corpus/template.txt create mode 100644 crates/tree-sitter-doctemplate/grammar/tree-sitter.json create mode 100644 crates/tree-sitter-doctemplate/src/lib.rs create mode 100644 crates/tree-sitter-doctemplate/test-templates/author-card.template create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/html.styles create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/html.template create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/metadata.html create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/styles.html create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/template.html create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/title-block.html create mode 100644 crates/tree-sitter-doctemplate/test-templates/html/toc.html create mode 100644 crates/tree-sitter-doctemplate/test-templates/main.template create mode 100644 crates/tree-sitter-doctemplate/test-templates/test.md 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 0000000000000000000000000000000000000000..69eb4bbb1bab81852f368af1d50d0bedb2f335fb GIT binary patch literal 58989 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 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, "