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. +:::