From 8c3b591de3126e68e35bd42308ec266b9252d283 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 4 Sep 2026 11:17:04 -0400 Subject: [PATCH] Evaluate the `{r}` inline spelling, unwrap the classic one (bd-inline-r-brace-spelling-not-evaluated-lk9s3iwe) Quarto has two spellings for an inline executable expression with deliberately different semantics: `{r} expr` escapes markdown specials in the value, and knitr's native `r expr` inserts the value as live markdown. quarto.org states the relationship as an equivalence -- `r x` is `{r} I(x)`. We implemented neither. The brace spelling was never evaluated: it fell through to the markdown parser as a code span, with no diagnostic and exit 0. The classic spelling was evaluated but wrapped in `.QuartoInlineRender()`, so it carried the brace form's escaping default rather than its own -- one spelling with one semantics, and it was the brace semantics wearing the classic spelling. Both halves came from the same regex, which had no alternation for `{r}` and applied the wrapper to the spelling Quarto 1 deliberately leaves alone. Q1's `execute-inline.ts` matches the brace form and nothing else; the native form reaches knitr unwrapped, which is why it passes markdown through. The pattern now carries a spelling marker and the replacement branches on it: `{r} expr` becomes `r .QuartoInlineRender(expr)`; `r expr` goes to knitr unwrapped, with only its separator normalized to a single space (knitr's own class is `[ #]`, so a tab-separated expression would otherwise stop evaluating). Wrapping the classic form as `.QuartoInlineRender(I(expr))` -- the documented equivalence taken literally -- would break `r NULL`, because `I(NULL)` is an error in R. An empty brace expression is now wrapped rather than passed through, so it fails loudly as it does under Q1 instead of rendering as a silent code span. That is reachable in an attribute value, the position whose text survives to this pass verbatim; in prose the reader normalizes `{r} ` to `{r}` first, so the pattern never sees it. Three behaviour changes for existing documents, all matching Quarto 1: 1. A `r expr` whose value contains markdown specials now renders as markdown rather than as literal text. This is the documented default for that spelling; authors wanting the escaping have it on `{r}`. 2. `r NULL` now renders as the empty string rather than the literal text `NULL`, because knitr's inline hook replaces the wrapper on this path. 3. A knitr document that *displays* the `{r}` syntax now fails the render if the expression doesn't resolve, unless the surrounding block is one the nested-cell mask protects. Q1 fails identically. The brace spelling brings its own fence hazard into the pattern's range, and a sharper one than the classic form's: ```{r} is in every document the knitr engine runs and needs only a trailing space to satisfy the separator. The existing prefix guard covers it, with regression tests for that shape alongside the display-fence ones already present (bd-knitr-inline-r-eats-fence-2ofk91x1). At render level the nested-cell mask intercepts that shape first, so the render-level test pins the chain rather than the guard alone -- established by mutation and documented on the test, so it isn't later "strengthened" into something vacuous. Tests: 27 -> 46 unit tests in preprocess.rs, and a new render-level suite `tests/integration/knitr_inline_expressions.rs` (8 tests) driving `render_document_to_file`, covering both spellings in prose, fenced-div attribute values and link titles, the escaping split, the documented I() equivalence, per-spelling NULL rendering, and fence survival. DOCS `guides/authoring/computations.qmd` covered executable cells and how to display one without running it, but said nothing about inline expressions. A reader had to go to quarto.org, which describes Quarto 1, or read the source. The new section covers the two spellings and why an author would pick one, with a table that shows the escaping contrast rather than describing it -- the same value renders as literal `**important**` in one row and as bold in the other. Since markdown admits inline HTML, that default is also the reason to prefer the brace spelling for a value you did not author, which the section says plainly. It documents the attribute positions too, which are easy to miss precisely because nothing in the rendered text changes when they fail. This branch is stacked on bd-0gwekaem (PR #657), which extends the nested-cell mask to inline expressions. Without it, an inline expression inside a display block still executes and the section's advice would be wrong. With it, the rule for inline expressions collapses into the rule the page already teaches for cells. The section says `markdown` block rather than "fenced code block" on purpose. bd-0gwekaem kept the display-class predicate narrow -- info string empty or `markdown` -- so a ```r block displays its text but does not stop an expression inside it from running. Measured on this tree: brace and classic spellings are both displayed verbatim inside a `markdown` block and inside a bare fence, and both execute inside a ```r block. That is Quarto 1's behaviour too; widening it is bd-tiidc899. No snapshot files added, modified or removed. Closes bd-inline-r-brace-spelling-not-evaluated-lk9s3iwe --- .../src/engine/knitr/preprocess.rs | 531 +++++++++++++----- .../integration/knitr_inline_expressions.rs | 356 ++++++++++++ crates/quarto-core/tests/integration/main.rs | 1 + docs/guides/authoring/computations.qmd | 102 ++++ 4 files changed, 857 insertions(+), 133 deletions(-) create mode 100644 crates/quarto-core/tests/integration/knitr_inline_expressions.rs diff --git a/crates/quarto-core/src/engine/knitr/preprocess.rs b/crates/quarto-core/src/engine/knitr/preprocess.rs index d6b0a1ad7..9a5bcee89 100644 --- a/crates/quarto-core/src/engine/knitr/preprocess.rs +++ b/crates/quarto-core/src/engine/knitr/preprocess.rs @@ -10,56 +10,126 @@ //! This module handles transformations of the markdown content before //! sending it to R/knitr for execution. //! -//! # Inline R Expressions +//! # The two inline spellings //! -//! Inline R expressions like `` `r 1+1` `` are transformed to use the -//! `.QuartoInlineRender()` wrapper function, which handles proper escaping -//! of special markdown characters in the output. +//! Quarto has two spellings for an inline executable expression, and they +//! carry **deliberately different** defaults for how the resulting value is +//! inserted into the document. `docs/computations/inline-code.qmd` on +//! quarto.org specifies both. //! //! ```text -//! Before: The answer is `r 1+1`. -//! After: The answer is `r .QuartoInlineRender(1+1)`. +//! `{r} expr` the cross-engine brace spelling; markdown specials in the +//! value are escaped, so a value of `**bold**` renders as the +//! literal text `**bold**` +//! +//! `r expr` knitr's native rmarkdown spelling, which predates Quarto; +//! the value is inserted as live markdown, so `**bold**` +//! renders bold +//! ``` +//! +//! The documentation states the relationship between them as an exact +//! equivalence: `` `r radius` `` is equivalent to `` `{r} I(radius)` ``, +//! `I()` being knitr's opt-in for "treat this as markdown". +//! +//! This pass implements the brace spelling and stays out of the way of the +//! classic one: +//! +//! ```text +//! Before: The answer is `{r} 1+1`, and so is `r 1+1`. +//! After: The answer is `r .QuartoInlineRender(1+1)`, and so is `r 1+1`. //! ``` //! +//! `.QuartoInlineRender()` (defined in `resources/rmd/execute.R`) is what +//! escapes markdown specials, and it is applied to the brace spelling only. +//! The classic spelling is handed to knitr unwrapped, where knitr's own +//! default inline hook inserts the value as markdown. That split is the whole +//! contract, and it is how Quarto 1 is built as well: `src/core/execute-inline.ts` +//! matches the brace form and nothing else, and `src/execute/rmd.ts` calls it +//! with the wrapper above. +//! +//! Wrapping the classic spelling as `.QuartoInlineRender(I(expr))` — the +//! documented equivalence taken literally — would express the same intent, +//! but `I(NULL)` is an error in R ("attempt to set an attribute on NULL"), so +//! it would turn `` `r NULL` `` from a rendered value into a failed render. +//! Leaving the expression alone reaches the same markdown-passthrough +//! semantics through knitr with no such edge. +//! +//! The two routes are not identical on every value: `NULL` renders as the +//! empty string through knitr's hook, where the wrapper yields the literal +//! text `NULL`. Quarto 1 splits the same way, so `` `r NULL` `` is empty and +//! `` `{r} NULL` `` is `NULL` in both implementations; `null_values_render_per_spelling` +//! in `tests/integration/knitr_inline_expressions.rs` pins both halves. +//! +//! # Two consequences of the classic spelling's markdown passthrough +//! +//! A value inserted through `` `r expr` `` reaches the document as live +//! markdown — and, since markdown admits inline HTML, as live HTML. That is +//! the documented contract and it matches Quarto 1, but it means a value +//! built from untrusted input should use the brace spelling, whose escaping +//! is the default for exactly this reason. +//! +//! Separately, and shared with Quarto 1's identical handler: two *adjacent* +//! spans like `` `{r} a``{r} b` `` evaluate only the first. The pattern +//! consumes the character before a match as its guard, so the second span has +//! no anchor left. This is inherent to the prefix-capture approach (Rust's +//! `regex` has no lookbehind) and is not a regression from either direction. +//! +//! # Why the classic spelling is rewritten at all, then +//! +//! Only to normalize its separator and trim its body. knitr's own inline +//! pattern accepts `[ #]` between the `r` and the expression, so a +//! tab-separated `` `rx` `` would never be evaluated if it reached knitr +//! verbatim. Re-emitting it with a single space keeps that spelling working. +//! The expression itself is untouched. +//! //! # This pass shifts byte offsets //! //! It runs on the output of `serialize_ast_to_qmd`, *after* the `SourceInfo` //! handed to `ExecutionContext` was built from that string -//! (`stage/stages/engine_execution.rs:432,467`). Every wrapped expression -//! makes the text 21 bytes longer, so offsets past it no longer agree with -//! that `SourceInfo`. This is harmless today — `ctx.source_info` is read by -//! the jupyter/ts engines and never by knitr — but anyone giving knitr real +//! (`stage/stages/engine_execution.rs:447,482`). A wrapped expression makes +//! the text longer and a trimmed one can make it shorter, so offsets past +//! either no longer agree with that `SourceInfo`. This is harmless today — `ctx.source_info` is read by the +//! jupyter/ts engines and never by knitr — but anyone giving knitr real //! source locations must reconcile the two first, or the locations will be //! silently wrong in exactly the documents that use inline R. use regex::Regex; use std::sync::LazyLock; -/// Regex pattern for inline R code: `` `r expression` `` +/// Regex pattern for an inline R expression in either spelling: +/// `` `{r} expression` `` or `` `r expression` ``. /// /// Matches: /// - Start of input, or a single character that is neither a backtick nor a /// backslash (captured group 1, re-emitted verbatim by the replacement) /// - Opening backtick -/// - Literal 'r' followed by exactly one space or tab -/// - The expression (captured group 2) — any characters except backticks, +/// - The spelling marker (captured group 2): either `{r}` or a bare `r` +/// - Exactly one space or tab +/// - The expression (captured group 3) — any characters except backticks, /// which may span lines /// - Closing backtick /// +/// Group 2 is what the replacement branches on; see the module docs for why +/// the two spellings are rewritten differently. +/// /// # Why the guard on group 1, and why `[ \t]` rather than `\s+` /// /// This pass runs over the **entire** serialized document — front matter /// included — so it must not mistake a fenced code block for an inline -/// expression. Without both guards it does exactly that: against a display -/// fence `` ```r `` the match anchors on the fence's *third* backtick, `\s+` -/// consumes the newline, and `[^`]+` swallows the block body up to the -/// closing fence, producing `` ```r .QuartoInlineRender()`` `` -/// and a fatal parse error that costs the whole page -/// (bd-knitr-inline-r-eats-fence-2ofk91x1). +/// expression. Both spellings have a fence shape that would otherwise anchor +/// a match on the fence's *last* opening backtick and let `[^`]+` swallow the +/// block body up to the closing fence, producing +/// `` ```r .QuartoInlineRender()`` `` and a fatal parse error +/// that costs the whole page (bd-knitr-inline-r-eats-fence-2ofk91x1): /// -/// That is not avoidable upstream: the qmd writer collapses `` ``` r ``, -/// `` ```{.r} `` and `` ```r `` into the single spelling `` ```r ``, so -/// every author spelling arrives here as the dangerous one. +/// - `` ```r `` — a display fence, for the classic branch. +/// - `` ```{r} `` — an executable cell, for the brace branch. This one is in +/// *every* document the knitr engine runs, and it needs only a trailing +/// space after the `{r}` to satisfy the `[ \t]` separator. +/// +/// Neither is avoidable upstream: the qmd writer collapses `` ``` r ``, +/// `` ```{.r} `` and `` ```r `` into the single spelling `` ```r ``, so every +/// author spelling arrives here as the dangerous one. /// /// The two guards are independent, and they are **not** interchangeable — /// each defends cases the other does not: @@ -67,18 +137,21 @@ use std::sync::LazyLock; /// - `(^|[^`\\])` — neither a backtick nor a backslash may precede the match. /// The backtick half is Quarto 1's guard from `src/core/execute-inline.ts`. /// **This is the load-bearing fence defense, and it covers every fence -/// shape**, three backticks or forty: the backtick that would anchor a -/// match is always itself preceded by a backtick. The backslash half is -/// ours, for the case where a fence spelling does *not* reach us with its -/// backticks adjacent — see below. Removing this guard reddens -/// `test_fence_inside_yaml_scalar_not_matched`, -/// `test_escaped_backtick_fence_not_matched` and -/// `test_backtick_prefixed_not_matched`. +/// shape**, three backticks or forty, in both spellings: the backtick that +/// would anchor a match is always itself preceded by a backtick. The +/// backslash half is ours, for the case where a fence spelling does *not* +/// reach us with its backticks adjacent — see below. Removing this guard +/// reddens `test_fence_inside_yaml_scalar_not_matched`, +/// `test_escaped_backtick_fence_not_matched`, +/// `test_backtick_prefixed_not_matched`, +/// `test_executable_cell_fence_with_trailing_space_not_matched` and their +/// brace-spelling counterparts. /// - `[ \t]` — a single space or tab, so a newline can never open an /// expression. This is knitr parity (its class is `[ #]`) plus /// defense-in-depth; it is *not* what stops a fence. Its own regression -/// case is a mid-prose `` `r\nx` ``, which no fence guard would catch: -/// removing it reddens `test_newline_after_r_not_matched` and nothing else. +/// cases are a mid-prose `` `r\nx` `` / `` `{r}\nx` ``, which no fence guard +/// would catch: removing it reddens `test_newline_after_r_not_matched` and +/// `test_newline_after_brace_not_matched` and nothing else. /// /// knitr implements the same idea with two negative lookbehinds plus a /// `[ #]` class (`knitr::all_patterns$md$inline.code`). Rust's `regex` crate @@ -130,21 +203,41 @@ static INLINE_R_PATTERN: LazyLock = LazyLock::new(|| { // Pattern breakdown: // (^|[^`\\]) - Start of input, or one character that is neither a // backtick nor a backslash (re-emitted verbatim) - // `r[ \t] - Opening backtick, 'r', exactly one space or tab + // ` - Opening backtick + // (\{r\}|r) - The spelling marker: brace form or classic form + // [ \t] - Exactly one space or tab // ([^`]+) - Capture the expression (anything except backticks) // ` - Closing backtick - Regex::new(r"(^|[^`\\])`r[ \t]([^`]+)`").expect("Invalid regex pattern for inline R") + Regex::new(r"(^|[^`\\])`(\{r\}|r)[ \t]([^`]+)`").expect("Invalid regex pattern for inline R") }); -/// Resolve inline R expressions by wrapping them with `.QuartoInlineRender()`. +/// The brace spelling's marker, as it appears in group 2 of the pattern. +const BRACE_MARKER: &str = "{r}"; + +/// Resolve inline R expressions into the form knitr will evaluate. +/// +/// - `` `{r} expr` `` becomes `` `r .QuartoInlineRender(expr)` ``, so markdown +/// specials in the value are escaped. +/// - `` `r expr` `` stays `` `r expr` `` — knitr evaluates it natively and +/// inserts the value as markdown. Only the separator is normalized to a +/// single space and the expression trimmed. /// -/// Transforms `` `r expr` `` to `` `r .QuartoInlineRender(expr)` ``. +/// See the module docs for why the two differ. /// /// The `.QuartoInlineRender()` wrapper function (defined in execute.R) handles: /// - Proper escaping of special markdown characters -/// - Conversion of NULL to "NULL" string -/// - Handling of `AsIs` class objects -/// - Vector formatting +/// - Conversion of NULL to the string "NULL" +/// - Handling of `AsIs` class objects — which is what makes the documented +/// `` `{r} I(expr)` `` opt-out work +/// +/// It does *not* format vectors: it returns a non-character value unchanged, +/// and the `paste(as.character(x), collapse = ", ")` collapse is knitr's +/// default inline hook, which runs for both spellings. Numeric rounding is +/// knitr's too. The wrapper's only effect is the three bullets above. +/// +/// Because the brace spelling rewrites *into* the classic spelling, this +/// function is idempotent: a second pass leaves an already-rewritten +/// expression alone rather than escaping its value twice. /// /// # Arguments /// @@ -152,12 +245,12 @@ static INLINE_R_PATTERN: LazyLock = LazyLock::new(|| { /// /// # Returns /// -/// The markdown with inline R expressions wrapped. +/// The markdown with inline R expressions resolved. /// /// # Examples /// /// ```ignore -/// let input = "The answer is `r 1+1`."; +/// let input = "The answer is `{r} 1+1`."; /// let output = resolve_inline_r_expressions(input); /// assert_eq!(output, "The answer is `r .QuartoInlineRender(1+1)`."); /// ``` @@ -168,20 +261,47 @@ pub fn resolve_inline_r_expressions(markdown: &str) -> String { // part of the match only so that a backtick or backslash can be // excluded, so it must be re-emitted verbatim. let prefix = caps.get(1).map_or("", |m| m.as_str()); - let expr = caps.get(2).map_or("", |m| m.as_str()); + let marker = caps.get(2).map_or("", |m| m.as_str()); + let expr = caps.get(3).map_or("", |m| m.as_str()); // Trim the expression to normalize whitespace let trimmed = expr.trim(); - if trimmed.is_empty() { - // Empty expressions are left as-is (they'll produce an R error) + if marker == BRACE_MARKER { + // Wrapped even when the expression is empty, matching Quarto + // 1's handler. `.QuartoInlineRender()` with no argument is an + // R error ("argument \"v\" is missing"), and a loud failure is + // what we want: leaving `` `{r} ` `` alone renders it as a + // literal code span with no diagnostic and exit 0, because + // knitr's own pattern requires a literal `` `r `` and never + // claims it. Silent non-evaluation of the brace spelling is + // the defect this module was fixed for. + // + // Reachable only where the trailing whitespace survives to + // this pass — an attribute value, where the text is written + // through verbatim (verified: the render fails with Q1's + // error). In prose it is not reachable, and cannot be from + // here: the reader normalizes `` `{r} ` `` to `` `{r}` `` + // before serialization, so no separator remains and the + // pattern correctly declines it. Quarto 1 errors there too, + // because `execute-inline.ts` scans the raw source ahead of + // any AST round-trip; that residual divergence is a property + // of where the two passes sit, not of this branch. + format!("{}`r .QuartoInlineRender({})`", prefix, trimmed) + } else if trimmed.is_empty() { + // The classic branch keeps the opposite treatment, for the + // same reason: knitr *does* claim `` `r ` `` and errors on + // it, so leaving the match untouched preserves a loud failure. + // Re-emitting it as `` `r ` `` would fall below knitr's + // `([^`]+)` and silence it. caps[0].to_string() } else { - format!("{}`r .QuartoInlineRender({})`", prefix, trimmed) + format!("{}`r {}`", prefix, trimmed) } }) .into_owned() } -/// Check if the markdown contains any inline R expressions. +/// Check if the markdown contains any inline R expressions, in either +/// spelling. /// /// This can be used to skip preprocessing if there's nothing to process. /// @@ -200,28 +320,44 @@ pub fn has_inline_r_expressions(markdown: &str) -> bool { mod tests { use super::*; - // === resolve_inline_r_expressions tests === + // === the brace spelling: `{r} expr` -> wrapped in .QuartoInlineRender === #[test] - fn test_simple_inline_r() { - let input = "The answer is `r 1+1`."; + fn test_brace_spelling_is_wrapped() { + let input = "The answer is `{r} 1+1`."; let output = resolve_inline_r_expressions(input); assert_eq!(output, "The answer is `r .QuartoInlineRender(1+1)`."); } #[test] - fn test_multiple_inline_r() { - let input = "First `r x` then `r y` and finally `r z`."; + fn test_brace_spelling_at_start() { + let input = "`{r} x` is the value."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, "`r .QuartoInlineRender(x)` is the value."); + } + + #[test] + fn test_brace_spelling_at_end() { + let input = "The value is `{r} x`"; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, "The value is `r .QuartoInlineRender(x)`"); + } + + #[test] + fn test_brace_spelling_in_attribute_value() { + // The shape that motivated the strand: an inline expression inside a + // fenced-div attribute value. + let input = r#"::: {#hero data-version="`{r} release_version`"}"#; let output = resolve_inline_r_expressions(input); assert_eq!( output, - "First `r .QuartoInlineRender(x)` then `r .QuartoInlineRender(y)` and finally `r .QuartoInlineRender(z)`." + r#"::: {#hero data-version="`r .QuartoInlineRender(release_version)`"}"# ); } #[test] - fn test_inline_r_with_complex_expression() { - let input = "The mean is `r mean(c(1, 2, 3))`."; + fn test_brace_spelling_with_complex_expression() { + let input = "The mean is `{r} mean(c(1, 2, 3))`."; let output = resolve_inline_r_expressions(input); assert_eq!( output, @@ -230,76 +366,179 @@ mod tests { } #[test] - fn test_inline_r_with_whitespace() { - // Extra whitespace around the expression should be trimmed - let input = "Value: `r x + 1 `."; + fn test_brace_spelling_with_whitespace_is_trimmed() { + let input = "Value: `{r} x + 1 `."; let output = resolve_inline_r_expressions(input); assert_eq!(output, "Value: `r .QuartoInlineRender(x + 1)`."); } #[test] - fn test_no_inline_r() { - let input = "No R code here, just `code` and `more code`."; + fn test_brace_spelling_with_tab_separator() { + let input = "Value: `{r}\tx`."; let output = resolve_inline_r_expressions(input); - assert_eq!(output, input); + assert_eq!(output, "Value: `r .QuartoInlineRender(x)`."); } #[test] - fn test_inline_code_without_r() { - // Regular inline code (without 'r ') should not be transformed - let input = "Use `print()` to output."; + fn test_multiple_brace_expressions() { + let input = "First `{r} x` then `{r} y`."; let output = resolve_inline_r_expressions(input); - assert_eq!(output, input); + assert_eq!( + output, + "First `r .QuartoInlineRender(x)` then `r .QuartoInlineRender(y)`." + ); } #[test] - fn test_inline_r_at_start() { - let input = "`r x` is the value."; + fn test_brace_spelling_with_as_is_opt_out() { + // The documented markdown opt-in for the brace form. The wrapper must + // be applied around it, not instead of it — `.QuartoInlineRender` + // passes `AsIs` through unescaped, which is what makes I() work. + let input = "Bold: `{r} I(b)`."; let output = resolve_inline_r_expressions(input); - assert_eq!(output, "`r .QuartoInlineRender(x)` is the value."); + assert_eq!(output, "Bold: `r .QuartoInlineRender(I(b))`."); } #[test] - fn test_inline_r_at_end() { - let input = "The value is `r x`"; + fn test_brace_spelling_with_string_literal() { + // String literals inside the expression must survive the rewrite. + let input = r#"Name: `{r} paste("Hello", "World")`."#; let output = resolve_inline_r_expressions(input); - assert_eq!(output, "The value is `r .QuartoInlineRender(x)`"); + assert_eq!( + output, + r#"Name: `r .QuartoInlineRender(paste("Hello", "World"))`."# + ); } #[test] - fn test_inline_r_multiline() { - let input = "First line `r a`.\nSecond line `r b`."; + fn test_brace_spelling_multiline_body() { + // Only the character right after the marker is constrained; the body + // may still span lines. + let input = "Value: `{r} sum(\n c(1, 2)\n)`."; let output = resolve_inline_r_expressions(input); assert_eq!( output, - "First line `r .QuartoInlineRender(a)`.\nSecond line `r .QuartoInlineRender(b)`." + "Value: `r .QuartoInlineRender(sum(\n c(1, 2)\n))`." ); } #[test] - fn test_inline_r_with_string_literal() { - // String literals inside expressions should work - let input = r#"Name: `r paste("Hello", "World")`."#; + fn test_empty_brace_expression_is_wrapped_so_r_errors() { + // `.QuartoInlineRender()` with no argument is an R error, which is + // what Quarto 1 produces. Leaving the span alone would render it as a + // literal code span with no diagnostic — silent non-evaluation of the + // brace spelling, the defect this module was fixed for. + // + // The render-level case this protects is an attribute value, the one + // position whose text reaches this pass with its whitespace intact; + // see the branch's comment for why prose cannot get here. + let input = "Empty: `{r} `."; let output = resolve_inline_r_expressions(input); - assert_eq!( - output, - r#"Name: `r .QuartoInlineRender(paste("Hello", "World"))`."# - ); + assert_eq!(output, "Empty: `r .QuartoInlineRender()`."); + } + + #[test] + fn test_empty_classic_expression_is_left_alone() { + // The opposite treatment, for the same reason: knitr claims + // `` `r ` `` itself and errors on it, so an untouched match stays + // loud. Re-emitting it as `` `r ` `` would fall below knitr's + // `([^`]+)` and silence it. + let input = "Empty: `r `."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); } #[test] - fn test_inline_r_preserves_surrounding_text() { - let input = "Before `r x` middle `r y` after"; + fn test_uppercase_brace_r_not_matched() { + let input = "This `{R} x` is not inline R."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + + #[test] + fn test_brace_without_separator_not_matched() { + let input = "This `{r}x` is not inline R."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + + // === the classic spelling: `r expr` -> left unwrapped for knitr === + + #[test] + fn test_classic_spelling_is_not_wrapped() { + // Quarto 1 never touches this spelling: knitr matches it itself and + // its default inline hook inserts the value as live markdown. Wrapping + // it would impose the brace form's escaping default on it. + let input = "The answer is `r 1+1`."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, "The answer is `r 1+1`."); + } + + #[test] + fn test_classic_spelling_whitespace_is_normalized() { + // The one edit the classic branch does make. knitr's own separator + // class is `[ #]`, so a tab-separated expression would not survive to + // be evaluated if we handed it through untouched. + let input = "Value: `r x + 1 `."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, "Value: `r x + 1`."); + } + + #[test] + fn test_classic_spelling_with_tab_separator_is_normalized() { + let input = "Value: `r\tx`."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, "Value: `r x`."); + } + + #[test] + fn test_classic_spelling_multiline_body_preserved() { + let input = "Value: `r sum(\n c(1, 2)\n)`."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, "Value: `r sum(\n c(1, 2)\n)`."); + } + + #[test] + fn test_both_spellings_in_one_document() { + // The whole contract in one line: same document, same value, two + // deliberately different escaping defaults. + let input = "Escaped `{r} x` and markdown `r x`."; let output = resolve_inline_r_expressions(input); assert_eq!( output, - "Before `r .QuartoInlineRender(x)` middle `r .QuartoInlineRender(y)` after" + "Escaped `r .QuartoInlineRender(x)` and markdown `r x`." ); } + #[test] + fn test_rewrite_is_idempotent() { + // A brace expression rewrites to the classic spelling, so a second + // pass must not wrap it again — that would re-escape a value the + // wrapper already escaped. + let input = "Escaped `{r} x` and markdown `r x`."; + let once = resolve_inline_r_expressions(input); + let twice = resolve_inline_r_expressions(&once); + assert_eq!(once, twice); + } + + // === neither spelling === + + #[test] + fn test_no_inline_r() { + let input = "No R code here, just `code` and `more code`."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + + #[test] + fn test_inline_code_without_r() { + let input = "Use `print()` to output."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + #[test] fn test_uppercase_r_not_matched() { - // Only lowercase 'r' should be matched let input = "This `R code` is not inline R."; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); @@ -307,7 +546,6 @@ mod tests { #[test] fn test_r_without_space_not_matched() { - // 'r' must be followed by whitespace let input = "This `rx` is not inline R."; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); @@ -315,10 +553,11 @@ mod tests { // === fenced-code-block guard tests (bd-knitr-inline-r-eats-fence-2ofk91x1) === // - // A fence's third backtick must never anchor an inline-R match. The qmd - // writer collapses `` ``` r ``, `` ```{.r} `` and `` ```r `` to the same - // `` ```r ``, so this is the only spelling the preprocessor ever sees and - // there is no source form an author could migrate to. + // A fence's last opening backtick must never anchor an inline-R match. + // Both spellings have a fence shape that would otherwise do exactly that: + // the display fence `` ```r `` for the classic branch, and the executable + // cell `` ```{r} `` — which every knitr document contains — for the brace + // branch. #[test] fn test_display_fence_not_matched() { @@ -329,9 +568,6 @@ mod tests { #[test] fn test_display_fence_with_following_text_not_matched() { - // The realistic shape: a fence, then prose. Without the guard the - // match runs from the fence's third backtick to the closing fence's - // first, swallowing the whole body. let input = "Before.\n\n```r\n1 + 1\n```\n\nAfter."; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); @@ -339,67 +575,106 @@ mod tests { #[test] fn test_four_backtick_display_fence_not_matched() { - // The writer widens the fence when the body contains a backtick. - // knitr's own lookbehinds miss this shape — they are anchored to - // ``^`` `` / ``\n`` ``, which a *fourth* backtick does not satisfy. - // Our prefix guard is not anchored, so it covers this like any other - // fence; the case is here as a recurrence guard, not to pin a - // particular guard. let input = "````r\nx <- `y`\n````"; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); } + #[test] + fn test_executable_cell_fence_not_matched() { + // The brace branch's own fence shape, and the one that appears in + // every document the knitr engine runs. + let input = "Before.\n\n```{r}\n1 + 1\n```\n\nAfter."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + + #[test] + fn test_executable_cell_fence_at_start_of_input_not_matched() { + // `^` is start-of-input, so the first backtick of a document-initial + // fence is reachable by the alternation's first branch. + let input = "```{r}\n1 + 1\n```\n\nAfter."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + + #[test] + fn test_executable_cell_fence_with_trailing_space_not_matched() { + // The acute case for the brace branch: a trailing space after `{r}` + // satisfies the `[ \t]` separator, so the prefix guard is the only + // thing standing between this fence and a match that swallows the + // cell body up to the closing fence. + let input = "Before.\n\n```{r} \n1 + 1\n```\n\nAfter."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + + #[test] + fn test_four_backtick_executable_cell_fence_not_matched() { + let input = "````{r} \nx <- `y`\n````"; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + #[test] fn test_fence_inside_yaml_scalar_not_matched() { - // The pass runs over the whole serialized document, front matter - // included. A fence spelling inside a scalar is mid-line, so knitr's - // line-anchored lookbehinds would miss it — the non-backtick prefix - // guard does not. - // Needs a later backtick in the document for the runaway match to - // find a closing delimiter — the executable cell every such document - // has, which is what makes this reachable in practice. let input = "---\ntitle: \"In the title: ```r blocks\"\n---\n\n```{r}\n1 + 1\n```"; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); } + #[test] + fn test_brace_fence_inside_yaml_scalar_not_matched() { + let input = "---\ntitle: \"In the title: ```{r} blocks\"\n---\n\n```{r}\n1 + 1\n```"; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + #[test] fn test_escaped_backtick_fence_not_matched() { - // The shape the preprocessor actually receives when a YAML scalar - // fails to parse as markdown: the `.yaml-markdown-syntax-error` - // fallback re-serializes the text with every backtick - // backslash-escaped, so the third backtick is preceded by `\` rather - // than by a backtick. An escaped backtick cannot open a code span, so - // it cannot open an inline R expression either. let input = "---\ntitle: \"[In the title: \\`\\`\\`r blocks]{.yaml-markdown-syntax-error}\"\n---\n\n```{r}\n1 + 1\n```"; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); } + #[test] + fn test_escaped_backtick_brace_fence_not_matched() { + let input = "---\ntitle: \"[In the title: \\`\\`\\`{r} blocks]{.yaml-markdown-syntax-error}\"\n---\n\n```{r}\n1 + 1\n```"; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + #[test] fn test_backtick_prefixed_not_matched() { - // A backtick immediately before `` `r `` can never open a legitimate - // inline expression. Matches Quarto 1's `(^|[^`])` guard. let input = "Text ``r x` more."; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); } + #[test] + fn test_backtick_prefixed_brace_not_matched() { + let input = "Text ``{r} x` more."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + #[test] fn test_newline_after_r_not_matched() { - // `\s+` let a newline open the expression; a single space or tab - // cannot. This is the defect's proximate cause. let input = "Text `r\nx` more."; let output = resolve_inline_r_expressions(input); assert_eq!(output, input); } + #[test] + fn test_newline_after_brace_not_matched() { + let input = "Text `{r}\nx` more."; + let output = resolve_inline_r_expressions(input); + assert_eq!(output, input); + } + #[test] fn test_inline_r_still_matched_next_to_a_fence() { - // The guard must not cost us a real expression that shares a - // document with a display fence. - let input = "```r\n1 + 1\n```\n\nThe answer is `r 1+1`."; + let input = "```r\n1 + 1\n```\n\nThe answer is `{r} 1+1`."; let output = resolve_inline_r_expressions(input); assert_eq!( output, @@ -408,23 +683,12 @@ mod tests { } #[test] - fn test_inline_r_with_tab_separator_still_matched() { - // `[ \t]`, not `[ ]`: a tab keeps working, so the change is a pure - // narrowing of the old `\s+`. - let input = "Value: `r\tx`."; - let output = resolve_inline_r_expressions(input); - assert_eq!(output, "Value: `r .QuartoInlineRender(x)`."); - } - - #[test] - fn test_multiline_expression_body_still_matched() { - // Only the *first* character after `r` is constrained; the body may - // still span lines. - let input = "Value: `r sum(\n c(1, 2)\n)`."; + fn test_inline_r_still_matched_next_to_an_executable_cell() { + let input = "```{r}\nx <- 1\n```\n\nThe answer is `{r} x`."; let output = resolve_inline_r_expressions(input); assert_eq!( output, - "Value: `r .QuartoInlineRender(sum(\n c(1, 2)\n))`." + "```{r}\nx <- 1\n```\n\nThe answer is `r .QuartoInlineRender(x)`." ); } @@ -432,6 +696,7 @@ mod tests { #[test] fn test_has_inline_r_true() { + assert!(has_inline_r_expressions("Value: `{r} x`.")); assert!(has_inline_r_expressions("Value: `r x`.")); } @@ -448,9 +713,9 @@ mod tests { #[test] fn test_has_inline_r_false_for_display_fence() { - // The fast path must agree with the replacement pass, or a document - // whose only "match" is a fence still pays for a full scan. assert!(!has_inline_r_expressions("```r\n1 + 1\n```")); assert!(!has_inline_r_expressions("````r\nx <- `y`\n````")); + assert!(!has_inline_r_expressions("```{r}\n1 + 1\n```")); + assert!(!has_inline_r_expressions("```{r} \n1 + 1\n```")); } } diff --git a/crates/quarto-core/tests/integration/knitr_inline_expressions.rs b/crates/quarto-core/tests/integration/knitr_inline_expressions.rs new file mode 100644 index 000000000..3c8018f02 --- /dev/null +++ b/crates/quarto-core/tests/integration/knitr_inline_expressions.rs @@ -0,0 +1,356 @@ +//! bd-inline-r-brace-spelling-not-evaluated-lk9s3iwe: Quarto's two inline +//! expression spellings, through the real render path. +//! +//! `` `{r} expr` `` is the cross-engine brace spelling quarto.org documents; +//! `` `r expr` `` is knitr's native rmarkdown spelling. Both evaluate, and +//! they insert the resulting value differently on purpose — the brace form +//! escapes markdown specials in the value, the classic form does not. +//! `docs/computations/inline-code.qmd` on quarto.org states the relationship +//! as an equivalence: `` `r x` `` == `` `{r} I(x)` ``. +//! +//! **Why this lives at the render level rather than in `preprocess.rs`.** The +//! unit tests there pin the rewrite the preprocessor performs. They cannot +//! see what R does with it — whether `.QuartoInlineRender` actually escapes, +//! whether an unwrapped expression actually reaches knitr's inline hook, +//! whether an expression inside an attribute value survives serialization. +//! Only a real render, through the entry `q2 render` itself uses, answers +//! those. +//! +//! Tests skip when knitr isn't installed. + +#![cfg(not(target_arch = "wasm32"))] + +use std::path::Path; +use std::sync::Arc; + +use tempfile::TempDir; + +use quarto_core::ProjectContext; +use quarto_core::engine::EngineRegistry; +use quarto_core::render_to_file::{RenderToFileOptions, render_document_to_file}; +use quarto_system_runtime::{NativeRuntime, SystemRuntime}; + +fn knitr_available() -> bool { + EngineRegistry::default() + .get("knitr") + .is_some_and(|e| e.is_available()) +} + +fn render_html(tmp: &TempDir, name: &str, content: &str) -> String { + let input = tmp.path().join(name); + std::fs::write(&input, content).unwrap(); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let project = ProjectContext::discover(&input, runtime.as_ref()) + .expect("project discovery for the inline-expression fixture"); + + let result = render_document_to_file( + &input, + "html", + &RenderToFileOptions::default(), + Some(&project), + runtime.clone(), + None, + None, + None, + ) + .unwrap_or_else(|e| panic!("render must succeed: {e}")); + + read_html(&result.output_path) +} + +fn read_html(path: &Path) -> String { + std::fs::read_to_string(path).expect("read rendered HTML") +} + +/// A document whose setup cell defines `version`, `star` and `b`, followed by +/// `body`. +fn doc(body: &str) -> String { + format!( + "---\ntitle: Inline expressions\nengine: knitr\n---\n\n\ + ```{{r}}\n#| echo: false\n\ + version <- paste0(\"2026.0\", \"8.1\")\n\ + star <- \"*emph*\"\n\ + b <- \"**bold**\"\n```\n\n\ + {body}\n" + ) +} + +/// The three positions the strand measured, in the brace spelling. The +/// attribute cases are the ones that motivated it: nothing about them is +/// visible to a text diff of the rendered page. +#[test] +fn brace_spelling_is_evaluated_in_prose_and_attributes() { + if !knitr_available() { + eprintln!( + "SKIP: knitr not available — brace_spelling_is_evaluated_in_prose_and_attributes" + ); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html( + &tmp, + "brace.qmd", + &doc("Prose: `{r} version`.\n\n\ + ::: {#hero data-version=\"`{r} version`\"}\n\ + Hero body.\n\ + :::\n\n\ + [link](https://example.com \"`{r} version`\")\n"), + ); + + // `version` is assembled at runtime from two string halves, so a literal + // echo of the source could not produce it. + assert!( + html.contains("Prose: 2026.08.1"), + "brace spelling must be evaluated in prose; html:\n{html}" + ); + assert!( + html.contains(r#"data-version="2026.08.1""#), + "brace spelling must be evaluated in a fenced-div attribute value; html:\n{html}" + ); + assert!( + html.contains(r#"title="2026.08.1""#), + "brace spelling must be evaluated in a link title; html:\n{html}" + ); + assert!( + !html.contains("{r}"), + "no brace expression may survive into the output; html:\n{html}" + ); + assert!( + !html.contains("QuartoInlineRender"), + "the wrapper must not reach the output" + ); +} + +/// The classic spelling in the same three positions — the control that proves +/// the spelling, not the position, was the variable. +#[test] +fn classic_spelling_is_evaluated_in_prose_and_attributes() { + if !knitr_available() { + eprintln!( + "SKIP: knitr not available — classic_spelling_is_evaluated_in_prose_and_attributes" + ); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html( + &tmp, + "classic.qmd", + &doc("Prose: `r version`.\n\n\ + ::: {#hero data-version=\"`r version`\"}\n\ + Hero body.\n\ + :::\n\n\ + [link](https://example.org \"`r version`\")\n"), + ); + + assert!( + html.contains("Prose: 2026.08.1"), + "classic spelling must be evaluated in prose; html:\n{html}" + ); + assert!( + html.contains(r#"data-version="2026.08.1""#), + "classic spelling must be evaluated in a fenced-div attribute value; html:\n{html}" + ); + assert!( + html.contains(r#"title="2026.08.1""#), + "classic spelling must be evaluated in a link title; html:\n{html}" + ); +} + +/// The brace spelling escapes markdown specials in the value. This is the +/// half of the contract that `.QuartoInlineRender` implements. +#[test] +fn brace_spelling_escapes_markdown_in_the_value() { + if !knitr_available() { + eprintln!("SKIP: knitr not available — brace_spelling_escapes_markdown_in_the_value"); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html(&tmp, "escape.qmd", &doc("Value: `{r} star`.")); + + assert!( + html.contains("Value: *emph*"), + "the brace spelling must insert the value as literal text; html:\n{html}" + ); + assert!( + !html.contains("emph"), + "the brace spelling must not let the value's markdown be interpreted; html:\n{html}" + ); +} + +/// The classic spelling inserts the value as live markdown. This is knitr's +/// own documented behaviour, and q2 gets it by not wrapping the expression. +#[test] +fn classic_spelling_inserts_the_value_as_markdown() { + if !knitr_available() { + eprintln!("SKIP: knitr not available — classic_spelling_inserts_the_value_as_markdown"); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html(&tmp, "markdown.qmd", &doc("Value: `r star`.")); + + assert!( + html.contains("emph"), + "the classic spelling must insert the value as markdown; html:\n{html}" + ); +} + +/// The documented equivalence, both sides in one render: +/// `` `r x` `` == `` `{r} I(x)` ``. +#[test] +fn as_is_makes_the_two_spellings_equivalent() { + if !knitr_available() { + eprintln!("SKIP: knitr not available — as_is_makes_the_two_spellings_equivalent"); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html( + &tmp, + "asis.qmd", + &doc("Native: `r b`.\n\nWrapped: `{r} I(b)`.\n"), + ); + + assert!( + html.contains("Native: bold"), + "the classic spelling must render the value's markdown; html:\n{html}" + ); + assert!( + html.contains("Wrapped: bold"), + "I() must opt the brace spelling into markdown; html:\n{html}" + ); +} + +/// The two spellings render `NULL` differently, and both halves are pinned +/// here because the classic half changed with this fix. +/// +/// The classic spelling reaches knitr's default inline hook, which yields +/// `paste(as.character(NULL), collapse = ", ")` — the empty string. The brace +/// spelling reaches `.QuartoInlineRender`, whose first branch turns `NULL` +/// into the literal text `NULL`. Quarto 1 splits exactly the same way; both +/// spans below were checked against `quarto` 99.9.9 and match byte for byte. +/// +/// This also covers the reason the classic spelling is not wrapped as +/// `.QuartoInlineRender(I(expr))`: `I(NULL)` is an error in R, so that +/// spelling of the documented equivalence would fail the whole render here. +#[test] +fn null_values_render_per_spelling() { + if !knitr_available() { + eprintln!("SKIP: knitr not available — null_values_render_per_spelling"); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html( + &tmp, + "null.qmd", + &doc("Classic: [`r NULL`]{.classic}. Brace: [`{r} NULL`]{.brace}.\n"), + ); + + assert!( + html.contains(r#""#), + "the classic spelling must render NULL as the empty string; html:\n{html}" + ); + assert!( + html.contains(r#"NULL"#), + "the brace spelling must render NULL as the literal text NULL; html:\n{html}" + ); +} + +/// A `` ```{r} `` opener carrying a trailing space is the brace branch's worst +/// fence shape: the space satisfies the pattern's `[ \t]` separator, so only +/// the prefix guard stands between it and a match that swallows the block body +/// (bd-knitr-inline-r-eats-fence-2ofk91x1). +/// +/// **This test pins the chain, not the guard alone — read this before +/// "strengthening" it.** Two layers upstream mean the shape cannot actually +/// reach `resolve_inline_r_expressions` in production: +/// +/// - A *top-level* cell can't carry the space at all. `write_codeblock` +/// (`crates/pampa/src/writers/qmd.rs`) regenerates the fence from the block's +/// attributes and ends it with a bare `writeln!`, so trailing whitespace is +/// gone before this pass runs. A fixture that puts the space on a real cell +/// tests nothing. +/// - Inside a display block the bytes *are* written through verbatim, but +/// `engine::nested_cell_mask::mask` rewrites the opener to +/// `` ```{.r q2-nested-executable} `` before serialization, so the literal +/// `{r}` the pattern needs is no longer there. +/// +/// Established by mutation, not by inspection: weakening the prefix guard to +/// `(^|[^\\])` leaves this test green, because the mask intercepts first. +/// Weakening the guard *and* disabling the mask turns the block into +/// `` ```r .QuartoInlineRender(SENTINEL)``` `` and reddens it. (Disabling the +/// mask alone reddens it differently — knitr then executes the nested cell and +/// the render dies, which is the defect the mask exists for.) +/// +/// So what this pins end-to-end is that a documented `` ```{r} `` inside a +/// display block survives unexecuted and unwrapped. The prefix guard's own +/// teeth for this shape are at unit level, in +/// `test_executable_cell_fence_with_trailing_space_not_matched`. +#[test] +fn nested_executable_fence_is_not_eaten_by_the_inline_pass() { + if !knitr_available() { + eprintln!( + "SKIP: knitr not available — nested_executable_fence_is_not_eaten_by_the_inline_pass" + ); + return; + } + let tmp = TempDir::new().unwrap(); + // Note the trailing space after `{r}` on the inner fence: that is the + // byte under test. `SENTINEL` stands in for the block body — if a match + // anchored on the inner fence, everything from there to the next backtick + // would be replaced by the wrapper and the sentinel would vanish. + let content = "---\ntitle: Trailing space\nengine: knitr\n---\n\n\ + ```{r}\ncat(paste0(\"O\", \"UT\"), \"\\n\")\n```\n\n\ + ````markdown\n```{r} \nSENTINEL\n```\n````\n\n\ + After.\n"; + let html = render_html(&tmp, "trailing.qmd", content); + + assert!( + html.contains("OUT"), + "the executable cell must still run; html:\n{html}" + ); + assert!( + html.contains("SENTINEL"), + "the display block body must survive intact; html:\n{html}" + ); + assert!( + !html.contains("QuartoInlineRender"), + "the wrapper must not reach the output; html:\n{html}" + ); + assert!(html.contains("After."), "the page must survive intact"); +} + +/// The guard must not cost a real brace expression that shares a document +/// with a display fence — the render-level counterpart of +/// `test_inline_r_still_matched_next_to_a_fence`. Without a case in this +/// spelling, no render-level test covers the only spelling the pass rewrites: +/// `knitr_display_fence.rs`'s `inline_r_still_evaluated_next_to_a_display_fence` +/// uses the classic spelling, which this pass now leaves alone, so it would +/// pass even if the rewrite were a no-op. +#[test] +fn brace_expression_still_evaluated_next_to_a_display_fence() { + if !knitr_available() { + eprintln!( + "SKIP: knitr not available — brace_expression_still_evaluated_next_to_a_display_fence" + ); + return; + } + let tmp = TempDir::new().unwrap(); + let html = render_html( + &tmp, + "beside.qmd", + &doc("``` r\ninstall.packages(\"cli\")\n```\n\nThe version is `{r} version`.\n"), + ); + + assert!( + html.contains("The version is 2026.08.1"), + "the brace expression must still be evaluated; html:\n{html}" + ); + assert!( + html.contains("sourceCode r"), + "the display fence must still render as a highlighted R block; html:\n{html}" + ); + assert!( + !html.contains("QuartoInlineRender"), + "the wrapper must not reach the output; html:\n{html}" + ); +} diff --git a/crates/quarto-core/tests/integration/main.rs b/crates/quarto-core/tests/integration/main.rs index 143d02c5e..67789513c 100644 --- a/crates/quarto-core/tests/integration/main.rs +++ b/crates/quarto-core/tests/integration/main.rs @@ -45,6 +45,7 @@ pub mod julia_engine_e2e; pub mod jupyter_integration; pub mod jupyter_kernel_cleanup; pub mod knitr_display_fence; +pub mod knitr_inline_expressions; pub mod language_catalog; pub mod language_pipeline; pub mod language_resolve; diff --git a/docs/guides/authoring/computations.qmd b/docs/guides/authoring/computations.qmd index aa8c97043..2d3d266cf 100644 --- a/docs/guides/authoring/computations.qmd +++ b/docs/guides/authoring/computations.qmd @@ -108,3 +108,105 @@ escape also means code blocks that genuinely contain doubled braces — Jinja or Mustache templates, GitHub Actions expressions — render correctly, where Quarto 1 would collapse them. ::: + +## Inline expressions + +A document can also compute a value **inline** --- inside a sentence, or +inside an attribute --- so that the result appears in place of the +expression. Quarto has two spellings for this, and they differ in how the +computed value is inserted. + +The **brace spelling** mirrors the cell syntax and works across engines: + +````markdown +The current release is `{r} release_version`. +```` + +The **classic spelling** is knitr's own, inherited from R Markdown, and +predates Quarto. It is available with the knitr engine only: + +````markdown +The current release is `r release_version`. +```` + +### The two spellings insert the value differently + +The brace spelling **escapes** Markdown's special characters in the value: +whatever the expression returns is shown as text, exactly as it came out. The +classic spelling inserts the value **as Markdown**, so formatting carried in +the value is interpreted. + +With `notice` set to `**important**`: + +| Written | Renders as | +|----------------------|-----------------| +| `` `{r} notice` `` | `**important**` | +| `` `r notice` `` | **important** | + +To opt the brace spelling into Markdown, wrap the value in knitr's `I()`: + +````markdown +The notice is `{r} I(notice)`. +```` + +That makes the two spellings equivalent: `` `r x` `` does the same thing as +`` `{r} I(x)` ``. + +Reach for the brace spelling when the value comes from somewhere you do not +control. Markdown admits inline HTML, so a value inserted as Markdown can +bring markup with it; the brace spelling's escaping is the default for +exactly that reason. + +### Where inline expressions work + +In prose, and in attribute values --- a fenced div's attributes, or a link +title: + +````markdown +The current release is `{r} release_version`. + +::: {#hero data-version="`{r} release_version`"} +Download the latest build. +::: + +[Download](https://example.com/dl "Version `{r} release_version`") +```` + +### Writing about an inline expression + +The rule is the one from [Displaying code cells without running +them](#displaying-code-cells-without-running-them), and nothing about it +changes for inline expressions: write it inside a `markdown` block and it is +displayed rather than run. + +````markdown +```markdown +The current release is `{r} release_version`. +``` +```` + +which renders as: + +```markdown +The current release is `{r} release_version`. +``` + +The expression is shown exactly as an author would type it, together with the +prose around it, so a reader can copy the whole line into their own document. +As with cells, the wrapping fence only needs to be longer than the longest run +of backticks inside it. + +The block has to be a `markdown` block, or a fence carrying no language at +all. A fence labelled with some other language --- ```` ```r ````, say --- +displays its text but does not stop an expression inside it from running, so +reach for `markdown` when what you are showing is Quarto syntax. + +::: callout-note +## Migrating from Quarto 1 + +Quarto 1 had a doubled-brace escape for inline expressions too: `` `{{r}} x` `` +was displayed as `` `{r} x` ``. Quarto 2 drops it for the same reason it drops +the doubled-brace escape for cells --- the `markdown` code block above does the +job, and leaving doubled braces alone is what lets Jinja and Mustache templates +render correctly. +:::