Skip to content

Commit aaacc13

Browse files
authored
Fix standalone markdown bugs found while planning escaping rewrite (#1900)
Fix a few issues that fable found before embarking on escaping rewritn: * **UTF-8 offsets**: `roxygen_parse_tag()` is now UTF-8 aware, so `findEndOfTag()` accepts and returns character offsets. Previously its byte offsets were combined with character offsets from `gregexpr()`/`substr()`, so a multibyte character inside a fragile Rd tag corrupted markdown processing of the text that followed — e.g. `\code{café} *x*` lost the emphasis and `` \code{café} `x` `` left the backticks literal. The same mismatch affected `strip_rd_tag()` on examples containing multibyte characters. `rdComplete()` is unaffected. * **Tag-less warnings**: `markdown()`'s fake default tag (`list(file = NA, line = NA)`) crashed in `basename()`, and `rd-family.R` passed the bare string `"family"`, which crashed on `tag$tag`. Both now pass `NULL`, which `warn_roxy_tag()` handles, so e.g. a level-1 heading in an `rd_family_title` prefix now warns instead of erroring. * **Dead code**: deleted unused `markdown_pass1()` plus seven helpers that were defined identically in both `markdown.R` and `markdown-code.R` — the `markdown.R` copies silently shadowed the others because of collation order. The one divergence (the "Multi-line `r ` markup" message) was reconciled to the version the snapshots expect. * **Stale docs**: removed the claim that markdown is interpreted inside the 2nd/3rd arguments of `\if`/`\ifelse`; it was never implemented — fragile tags are protected together with all their arguments. * **Fast path**: `markdown_evaluate()` now skips the commonmark parse when the text contains no backtick or tilde (tilde-fenced ```` ~~~{r} ```` blocks parse and evaluate today, so `~` stays in the gate), so the many tags without any code don't pay for a full parse.
1 parent 3dd116b commit aaacc13

12 files changed

Lines changed: 94 additions & 283 deletions

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# roxygen2 (development version)
22

3+
* Markdown processing now handles multibyte characters inside Rd tags correctly; previously a tag like `\code{café}` would corrupt the markdown interpretation of the text that followed it.
4+
* Markdown warnings triggered by a `rd_family_title` prefix (e.g. for an unsupported level 1 heading) no longer error.
35
* Markdown link targets are now resolved against a per-run index of each package's help topics, instead of one `help()` call per topic and package. This substantially speeds up documenting packages with many cross-reference links, e.g. `roxygenize()` on testthat is about a third faster.
46
* S7 methods for `[`, `[[`, `[<-`, and `[[<-` now generate valid usage (#1883).
57
* `Config/roxygen2/` flag fields in `DESCRIPTION` (like `markdown`) are now parsed case-insensitively, so `true` and `True` work as well as `TRUE`, and an invalid value gives a clear error (#1875).

R/markdown-code.R

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@
4949
#' @keywords internal
5050
markdown_evaluate <- function(text) {
5151
text <- paste(text, collapse = "\n")
52+
# Code is delimited by backticks (`r ...`, ``` fences) or tilde fences
53+
# (~~~{r}), so without either we can skip the (relatively expensive)
54+
# markdown parsing altogether
55+
if (!grepl("[`~]", text)) {
56+
return(text)
57+
}
5258
mdxml <- xml_ns_strip(md_to_mdxml(text, sourcepos = TRUE))
5359
code_nodes <- xml_find_all(mdxml, ".//code | .//code_block")
5460
rcode_nodes <- keep(code_nodes, is_markdown_code_node)
@@ -165,7 +171,7 @@ re_set_all_pos <- function(text, pos, value, nodes) {
165171
# continuation lines: https://github.com/commonmark/cmark/issues/296
166172
types <- xml_name(nodes)
167173
if (any(types == "code" & pos$start_line != pos$end_line)) {
168-
cli::cli_abort("multi-line `r ` markup is not supported", call = NULL)
174+
cli::cli_abort("Multi-line `r ` markup is not supported.", call = NULL)
169175
}
170176

171177
# Need to split the string, because of the potential multi-line

R/markdown-escaping.R

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@
44
#' `escape_rd_for_md()` replaces fragile Rd tags with placeholders, to avoid
55
#' interpreting them as markdown. `unescape_rd_for_md()` puts the original
66
#' text back in place of the placeholders after the markdown parsing is done.
7-
#' The fragile tags are listed in `escaped_for_md`.
8-
#'
9-
#' Some Rd macros are treated specially:
10-
#'
11-
#' * For `if`, markdown is only allowed in the second argument.
12-
#' * For `ifelse` markdown is allowed in the second and third arguments.
7+
#' The fragile tags are listed in `escaped_for_md`. A fragile tag is
8+
#' protected together with all of its brace arguments, so markdown is never
9+
#' interpreted inside them.
1310
#'
1411
#' @param text Input text. Potentially contains Rd and/or
1512
#' markdown markup.

R/markdown.R

Lines changed: 6 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
markdown <- function(text, tag = NULL, sections = FALSE) {
2-
tag <- tag %||% list(file = NA, line = NA)
32
expanded_text <- tryCatch(
43
markdown_evaluate(text),
54
error = function(e) {
@@ -17,199 +16,6 @@ markdown <- function(text, tag = NULL, sections = FALSE) {
1716
)
1817
}
1918

20-
#' Expand the embedded inline code
21-
#'
22-
#' @details
23-
#' For example this becomes two: `r 1+1`.
24-
#' Variables can be set and then reused, within the same
25-
#' tag: `r x <- 100; NULL`
26-
#' The value of `x` is `r x`.
27-
#'
28-
#' We have access to the internal functions of the package, e.g.
29-
#' since this is _roxygen2_, we can refer to the internal `markdown`
30-
#' function, and this is `TRUE`: `r is.function(markdown)`.
31-
#'
32-
#' To insert the name of the current package: `r packageName()`.
33-
#'
34-
#' The `iris` data set has `r ncol(iris)` columns:
35-
#' `r paste0("\x60\x60", colnames(iris), "\x60\x60", collapse = ", ")`.
36-
#'
37-
#' ```{r}
38-
#' # Code block demo
39-
#' x + 1
40-
#' ```
41-
#'
42-
#' Chunk options:
43-
#'
44-
#' ```{r results = "hold"}
45-
#' names(mtcars)
46-
#' nrow(mtcars)
47-
#' ```
48-
#'
49-
#' Plots:
50-
#'
51-
#' ```{r test-figure}
52-
#' plot(1:10)
53-
#' ```
54-
#'
55-
#' Alternative knitr engines:
56-
#'
57-
#' ```{verbatim}
58-
#' #| file = "tests/testthat/example.Rmd"
59-
#' ```
60-
#'
61-
#' Also see `vignette("rd-formatting")`.
62-
#'
63-
#' @param text Input text.
64-
#' @return
65-
#' Text with R code expanded.
66-
#' A character vector of the same length as the input `text`.
67-
#'
68-
#' @keywords internal
69-
70-
markdown_pass1 <- function(text) {
71-
text <- paste(text, collapse = "\n")
72-
mdxml <- xml_ns_strip(md_to_mdxml(text, sourcepos = TRUE))
73-
code_nodes <- xml_find_all(mdxml, ".//code | .//code_block")
74-
rcode_nodes <- keep(code_nodes, is_markdown_code_node)
75-
if (length(rcode_nodes) == 0) {
76-
return(text)
77-
}
78-
rcode_pos <- parse_md_pos(map_chr(rcode_nodes, xml_attr, "sourcepos"))
79-
rcode_pos <- work_around_cmark_sourcepos_bug(text, rcode_pos)
80-
out <- eval_code_nodes(rcode_nodes)
81-
re_set_all_pos(text, rcode_pos, out, rcode_nodes)
82-
}
83-
84-
# Work around commonmark sourcepos bug for inline R code
85-
# https://github.com/r-lib/roxygen2/issues/1353
86-
work_around_cmark_sourcepos_bug <- function(text, rcode_pos) {
87-
if (Sys.getenv("ROXYGEN2_NO_SOURCEPOS_WORKAROUND", "") != "") {
88-
return(rcode_pos)
89-
}
90-
91-
lines <- strsplit(text, "\n", fixed = TRUE)[[1]]
92-
93-
for (l in seq_len(nrow(rcode_pos))) {
94-
# Do not try to fix multi-line code, we error for that (below)
95-
if (rcode_pos$start_line[l] != rcode_pos$end_line[l]) {
96-
next
97-
}
98-
line <- lines[rcode_pos$start_line[l]]
99-
start <- rcode_pos$start_column[l]
100-
101-
# Maybe correct? At some point this will be fixed upstream, hopefully.
102-
if (substr(line, start - 1, start + 1) == "`r ") {
103-
next
104-
}
105-
106-
# Maybe indented and we can shift it?
107-
# It is possible that the shift that we try accidentally matches
108-
# "`r ", but it seems to be extremely unlikely. An example is this:
109-
# #' ``1`r `` `r 22*10`
110-
# (seven spaces after the #', so an indent of six spaces. If we shift
111-
# the real "`r " left by six characters, there happens to be another
112-
# "`r " there.
113-
114-
m <- regexpr("^[ ]+", line)
115-
indent <- attr(m, "match.length")
116-
if (
117-
m > 0L &&
118-
substr(line, start - 1 + indent, start + 1 + indent) == "`r "
119-
) {
120-
rcode_pos$start_column[l] <- rcode_pos$start_column[l] + indent
121-
rcode_pos$end_column[l] <- rcode_pos$end_column[l] + indent
122-
}
123-
}
124-
125-
rcode_pos
126-
}
127-
128-
is_markdown_code_node <- function(x) {
129-
info <- xml_attr(x, "info")
130-
substr(xml_text(x), 1, 2) == "r " ||
131-
(!is.na(info) && grepl("^[{][a-zA-z]+[}, ]", info))
132-
}
133-
134-
parse_md_pos <- function(text) {
135-
nums <- map(strsplit(text, "[:-]"), as.integer)
136-
data.frame(
137-
start_line = map_int(nums, \(x) x[[1]]),
138-
start_column = map_int(nums, \(x) x[[2]]),
139-
end_line = map_int(nums, \(x) x[[3]]),
140-
end_column = map_int(nums, \(x) x[[4]])
141-
)
142-
}
143-
144-
eval_code_nodes <- function(nodes) {
145-
evalenv <- roxy_meta_get("evalenv")
146-
# This should only happen in our test cases
147-
if (is.null(evalenv)) {
148-
evalenv <- new.env(parent = baseenv())
149-
}
150-
151-
map_chr(nodes, eval_code_node, env = evalenv)
152-
}
153-
154-
eval_code_node <- function(node, env) {
155-
if (xml_name(node) == "code") {
156-
# write knitr markup for inline code
157-
text <- paste0("`", xml_text(node), "`")
158-
} else {
159-
lang <- xml_attr(node, "info")
160-
# write knitr markup for fenced code
161-
text <- paste0("```", if (!is.na(lang)) lang, "\n", xml_text(node), "```\n")
162-
}
163-
164-
chunk_opts <- utils::modifyList(
165-
knitr_chunk_defaults(),
166-
as.list(roxy_meta_get("knitr_chunk_options", NULL))
167-
)
168-
169-
roxy_knit(text, env, chunk_opts)
170-
}
171-
172-
knitr_chunk_defaults <- function() {
173-
list(
174-
error = FALSE,
175-
fig.path = "man/figures/",
176-
fig.process = basename,
177-
comment = "#>",
178-
collapse = TRUE
179-
)
180-
}
181-
182-
re_set_all_pos <- function(text, pos, value, nodes) {
183-
# Cmark has a bug when reporting source positions for multi-line
184-
# code tags, and it does not count the indenting space in the
185-
# continuation lines: https://github.com/commonmark/cmark/issues/296
186-
types <- xml_name(nodes)
187-
if (any(types == "code" & pos$start_line != pos$end_line)) {
188-
cli::cli_abort("Multi-line `r ` markup is not supported.", call = NULL)
189-
}
190-
191-
# Need to split the string, because of the potential multi-line
192-
# code tags, and then also recode the positions
193-
lens <- nchar(strsplit(text, "\n", fixed = TRUE)[[1]])
194-
shifts <- c(0, cumsum(lens + 1L))
195-
shifts <- shifts[-length(shifts)]
196-
start <- shifts[pos$start_line] + pos$start_column
197-
end <- shifts[pos$end_line] + pos$end_column
198-
199-
# Create intervals for the parts we keep
200-
keep_start <- c(1, end + 2L)
201-
keep_end <- c(start - 2L, nchar(text))
202-
203-
# Now piece them together
204-
out <- paste0(
205-
substring(text, keep_start, keep_end),
206-
c(value, ""),
207-
collapse = ""
208-
)
209-
attributes(out) <- attributes(text)
210-
out
211-
}
212-
21319
markdown_pass2 <- function(text, tag = NULL, sections = FALSE) {
21420
esc_text_linkrefs <- add_linkrefs_to_md(text)
21521

@@ -552,11 +358,16 @@ escape_comment <- function(x) {
552358
mdxml_heading <- function(xml, state) {
553359
level <- xml_attr(xml, "level")
554360
if (!state$has_sections && level == 1) {
361+
if (is.null(state$tag)) {
362+
tag_name <- "this tag"
363+
} else {
364+
tag_name <- paste0("@", state$tag$tag)
365+
}
555366
warn_roxy_tag(
556367
state$tag,
557368
c(
558369
"markdown translation failed",
559-
x = "Level 1 headings are not supported in @{state$tag$tag}",
370+
x = "Level 1 headings are not supported in {tag_name}",
560371
i = "Do you want to put the heading in @description or @details?"
561372
)
562373
)

R/rd-family.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ topics_process_family_prefix <- function(family) {
3434
return(default)
3535
}
3636

37-
prefix <- markdown(prefix, tag = "family")
37+
prefix <- markdown(prefix)
3838
# Ensure prefix ends with a colon (#1656)
3939
if (!grepl(":$", prefix)) {
4040
prefix <- paste0(prefix, ":")

man/markdown-internals.Rd

Lines changed: 3 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/markdown_pass1.Rd

Lines changed: 0 additions & 66 deletions
This file was deleted.

0 commit comments

Comments
 (0)