Don't execute an inline expression that is being displayed - #657
Merged
gordonwoodhull merged 1 commit intoSep 4, 2026
Merged
Conversation
Wrapping executable code in a longer fence is how an author writes about
it: a `{r}` cell inside a ````markdown block is shown to the reader
rather than run. That held for cells and not for inline expressions. In
a document with any live R cell, an inline `r v` written inside a
display block was evaluated, so the reader saw the value where the
author had written the example — and there was no way to show anyone
what an inline expression looks like.
Inline expressions are found by scanning text, not by walking the AST:
knitr's `all_patterns$md$inline.code` and q2's own
`resolve_inline_r_expressions` both run over the whole serialized
document, and neither tells a fenced code block from prose. The
nested-cell mask already closed that gap for fence openers, but it
matched whole opener lines only, so an expression sitting mid-line went
straight through.
`mask` now rewrites inline expression markers alongside openers,
inserting its marker directly after the opening backtick; `unmask`
deletes it again. Because the rewrite only ever inserts a fixed string,
the restore is byte-exact by construction — which reconcile depends on,
since a byte of drift makes it replace the display block and attribute
the author's example to knitr's intermediate file.
Both spellings are covered: knitr's native `r x` and the cross-engine
`{lang} x`. The brace form matches any language rather than only `r`,
mirroring the opener mask's language class, so `{python}` is covered the
day it starts evaluating.
The display-class predicate is deliberately unchanged — empty info
string or `markdown`. An author who writes ````r to get highlighting on
the example still gets no protection, for inline expressions or for
nested cells. That matches Quarto 1, which executes there too, and
widening it is a change to the cell escape as much as to this one, so it
is filed separately as bd-tiidc899.
Tests: 16 added (14 unit, 2 render-tier through the real knitr path),
none removed, no snapshots touched. Workspace run 13692 passed / 199
skipped, up exactly 16 from the 13676 baseline.
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
gordonwoodhull
deleted the
bugfix/bd-0gwekaem-nested-cell-mask-inline-expressions
branch
September 4, 2026 19:17
gordonwoodhull
added a commit
that referenced
this pull request
Sep 4, 2026
…-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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wrapping executable code in a longer fence is how an author writes about it — a
{r}cell inside a ````markdown block is shown to the reader rather than run. That held for cells and not for inline expressions. In a document with any live R cell, this:rendered as
Inline: EVALUATED. The reader got the value where the author had written the example, and there was no way to show anyone what an inline expression looks like.Inline expressions are found by scanning text, not by walking the AST. knitr's
all_patterns$md$inline.codeand q2's ownresolve_inline_r_expressionsboth run over the whole serialized document, and neither tells a fenced code block from prose. The nested-cell mask already closed that gap for fence openers — it rewrites{r}to{.r q2-nested-executable}before the engine sees the document and restores it afterwards — but it matched whole opener lines, so an expression sitting mid-line went straight through.masknow rewrites inline expression markers alongside openers, inserting its marker directly after the opening backtick;unmaskdeletes it again.What the engine actually receives
For a document with a live cell, a
markdown block, and ar block, the masked intermediate looks like this:The live cell at the top is untouched — it is a real cell, not a display block. The two
markdownblocks carry the marker. Therblock does not, which is the scope decision below.Running that through
resolve_inline_r_expressions, the pass that actually wraps expressions for knitr, shows why the rewrite is the right one:The scanner needs a backtick immediately followed by
rand a separator. The marker breaks that adjacency and nothing else about the author's text moves.Both rewrites in one block, including through a blockquote — which only
unmaskever sees, sincemaskworks on aCodeBlock's text after the reader has stripped the>:Note the two forms differ. The opener rewrite has to neutralize the info string, so it becomes
{.r q2-nested-executable}— the leading.is what stops the chunk scanner. The inline rewrite only prefixes, leaving{r}andrliterally intact. Because it only ever inserts a fixed string, the restore is byte-exact by construction — which reconcile depends on, since a byte of drift makes it treat the display block as changed, replace it, and attribute the author's example to knitr's intermediate file.Coverage
Both spellings: knitr's native
`r x`and the cross-engine`{lang} x`. The brace form matches any language rather than onlyr, mirroring the opener mask's language class, so{python}is covered the day it starts evaluating. The separator class is the union of knitr's[ #]and q2's[ \t]— the mask has to be a superset of every scanner that could claim the expression, or it leaves a hole.The display-class predicate is deliberately unchanged: a display block is one whose info string is empty or
markdown. An author who writes ````r to get syntax highlighting on the example still gets no protection, for inline expressions or for nested cells. That matches Quarto 1, which executes there too, and widening it is a change to the cell escape as much as to this one — filed separately as bd-tiidc899.The brace spelling is not evaluated by q2 yet (bd-inline-r-brace-spelling-not-evaluated-lk9s3iwe), so that half of this is latent today. It is handled here rather than deferred because the mask and the evaluator are independent seams, and the one deciding not to execute should not wait on the one deciding how.
Tests
16 added, in their own tier so the frozen T1–T24 seam spec is untouched. Two run through the real render path with real knitr; the rest exercise
mask/unmaskdirectly — round-trip byte-exactness across nine inline shapes, a live cell's backtick-quoted R name left alone, the fence-backtick guard, and a row binding the masked text toresolve_inline_r_expressionsitself, so the rewrite is proved against the production scanner rather than against its own regex.Closes bd-0gwekaem.