From 0b23edb46293a641020ec9958fd711396c86c9b2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 16:46:27 -0600 Subject: [PATCH 01/25] Move link resolution to own file --- R/markdown-link-resolve.R | 126 ++++++++++++++++++ R/markdown-link.R | 113 ---------------- R/utils.R | 15 --- .../testthat/_snaps/markdown-link-resolve.md | 40 ++++++ tests/testthat/_snaps/markdown-link.md | 40 ------ tests/testthat/test-markdown-link-resolve.R | 52 ++++++++ tests/testthat/test-markdown-link.R | 52 -------- 7 files changed, 218 insertions(+), 220 deletions(-) create mode 100644 R/markdown-link-resolve.R create mode 100644 tests/testthat/_snaps/markdown-link-resolve.md create mode 100644 tests/testthat/test-markdown-link-resolve.R diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R new file mode 100644 index 00000000..48bce03c --- /dev/null +++ b/R/markdown-link-resolve.R @@ -0,0 +1,126 @@ +resolve_link_package <- function( + topic, + me = NULL, + pkgdir = NULL, + state = NULL +) { + me <- me %||% roxy_meta_get("current_package") + # this is from the roxygen2 tests, should not happen on a real package + if (is.null(me) || is.na(me) || me == "") { + return(NA_character_) + } + + # if it is in the current package, then no need for package name, right? + if (has_topic(topic, me)) { + return(NA_character_) + } + + # try packages in depends, imports, suggests first, error on name clashes + pkgs <- local_pkg_deps(pkgdir) + + pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] + pkg_has_topic <- map_chr(pkg_has_topic, function(p) { + p0 <- find_reexport_source(topic, p) + if (is.na(p0)) p else p0 + }) + pkg_has_topic <- unique(pkg_has_topic) + base <- base_packages() + if (length(pkg_has_topic) == 0) { + # fall through to check base packages as well + } else if (length(pkg_has_topic) == 1) { + if (pkg_has_topic %in% base) { + return(NA_character_) + } else { + return(pkg_has_topic) + } + } else { + warn_roxy_tag( + state$tag, + c( + "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg_has_topic}}", + i = "Qualify topic explicitly with a package name when linking to it." + ) + ) + return(NA_character_) + } + + # try base packages as well, take the first hit, + # there should not be any name clashes, anyway + for (bp in base) { + if (has_topic(topic, bp)) return(NA_character_) + } + + warn_roxy_tag( + state$tag, + c( + "Could not resolve link to topic {.val {topic}} in the dependencies or base packages", + "i" = paste( + "If you haven't documented {.val {topic}} yet, or just changed its name, this is normal.", + "Once {.val {topic}} is documented, this warning goes away." + ), + "i" = "Make sure that the name of the topic is spelled correctly.", + "i" = "Always list the linked package as a dependency.", + "i" = "Alternatively, you can fully qualify the link with a package name." + ) + ) + + NA_character_ +} + +# Note that this caches the result regardless of +# pkgdir! pkgdir is mainly for testing, in which case you +# need to clear the cache manually. +local_pkg_deps <- function(pkgdir = NULL) { + if (!is.null(mddata[["deps"]])) { + return(mddata[["deps"]]) + } + pkgdir <- pkgdir %||% roxy_meta_get("current_package_dir") + deps <- desc::desc_get_deps(pkgdir) + deps <- deps[deps$package != "R", ] + deps <- deps[deps$type %in% c("Depends", "Imports", "Suggests"), ] + deps$package +} + +# this is mostly from downlit +is_exported <- function(name, package) { + name %in% getNamespaceExports(ns_env(package)) +} + +is_reexported <- function(name, package) { + if (package == "base") { + return(FALSE) + } + is_imported <- env_has(ns_imports_env(package), name) + is_imported && is_exported(name, package) +} + +find_reexport_source <- function(topic, package) { + ns <- ns_env(package) + if (!env_has(ns, topic, inherit = TRUE)) { + return(NA_character_) + } + + obj <- env_get(ns, topic, inherit = TRUE) + if (is.primitive(obj)) { + # primitive functions all live in base + "base" + } else if (is.function(obj)) { + ## For functions, we can just take their environment. + ns_env_name(get_env(obj)) + } else { + ## For other objects, we need to check the import env of the package, + ## to see where 'topic' is coming from. The import env has redundant + ## information. It seems that we just need to find a named list + ## entry that contains `topic`. + imp <- getNamespaceImports(ns) + imp <- imp[names(imp) != ""] + wpkgs <- vapply(imp, `%in%`, x = topic, FUN.VALUE = logical(1)) + + if (!any(wpkgs)) { + return(NA_character_) + } + pkgs <- names(wpkgs)[wpkgs] + # Take the last match, in case imports have name clashes. + pkgs[[length(pkgs)]] + } +} diff --git a/R/markdown-link.R b/R/markdown-link.R index 01607a1b..98fb4284 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -180,119 +180,6 @@ parse_link <- function(destination, contents, state) { } } -resolve_link_package <- function( - topic, - me = NULL, - pkgdir = NULL, - state = NULL -) { - me <- me %||% roxy_meta_get("current_package") - # this is from the roxygen2 tests, should not happen on a real package - if (is.null(me) || is.na(me) || me == "") { - return(NA_character_) - } - - # if it is in the current package, then no need for package name, right? - if (has_topic(topic, me)) { - return(NA_character_) - } - - # try packages in depends, imports, suggests first, error on name clashes - pkgs <- local_pkg_deps(pkgdir) - - pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] - pkg_has_topic <- map_chr(pkg_has_topic, function(p) { - p0 <- find_reexport_source(topic, p) - if (is.na(p0)) p else p0 - }) - pkg_has_topic <- unique(pkg_has_topic) - base <- base_packages() - if (length(pkg_has_topic) == 0) { - # fall through to check base packages as well - } else if (length(pkg_has_topic) == 1) { - if (pkg_has_topic %in% base) { - return(NA_character_) - } else { - return(pkg_has_topic) - } - } else { - warn_roxy_tag( - state$tag, - c( - "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg_has_topic}}", - i = "Qualify topic explicitly with a package name when linking to it." - ) - ) - return(NA_character_) - } - - # try base packages as well, take the first hit, - # there should not be any name clashes, anyway - for (bp in base) { - if (has_topic(topic, bp)) return(NA_character_) - } - - warn_roxy_tag( - state$tag, - c( - "Could not resolve link to topic {.val {topic}} in the dependencies or base packages", - "i" = paste( - "If you haven't documented {.val {topic}} yet, or just changed its name, this is normal.", - "Once {.val {topic}} is documented, this warning goes away." - ), - "i" = "Make sure that the name of the topic is spelled correctly.", - "i" = "Always list the linked package as a dependency.", - "i" = "Alternatively, you can fully qualify the link with a package name." - ) - ) - - NA_character_ -} - -# this is mostly from downlit -is_exported <- function(name, package) { - name %in% getNamespaceExports(ns_env(package)) -} - -is_reexported <- function(name, package) { - if (package == "base") { - return(FALSE) - } - is_imported <- env_has(ns_imports_env(package), name) - is_imported && is_exported(name, package) -} - -find_reexport_source <- function(topic, package) { - ns <- ns_env(package) - if (!env_has(ns, topic, inherit = TRUE)) { - return(NA_character_) - } - - obj <- env_get(ns, topic, inherit = TRUE) - if (is.primitive(obj)) { - # primitive functions all live in base - "base" - } else if (is.function(obj)) { - ## For functions, we can just take their environment. - ns_env_name(get_env(obj)) - } else { - ## For other objects, we need to check the import env of the package, - ## to see where 'topic' is coming from. The import env has redundant - ## information. It seems that we just need to find a named list - ## entry that contains `topic`. - imp <- getNamespaceImports(ns) - imp <- imp[names(imp) != ""] - wpkgs <- vapply(imp, `%in%`, x = topic, FUN.VALUE = logical(1)) - - if (!any(wpkgs)) { - return(NA_character_) - } - pkgs <- names(wpkgs)[wpkgs] - # Take the last match, in case imports have name clashes. - pkgs[[length(pkgs)]] - } -} - #' Dummy page to test roxygen's markdown formatting #' #' Links are very tricky, so I'll put in some links here: diff --git a/R/utils.R b/R/utils.R index 4456b93f..9bc067ef 100644 --- a/R/utils.R +++ b/R/utils.R @@ -253,18 +253,3 @@ base_packages <- function() { ) } } - -# Note that this caches the result regardless of -# pkgdir! pkgdir is mainly for testing, in which case you -# need to clear the cache manually. - -local_pkg_deps <- function(pkgdir = NULL) { - if (!is.null(mddata[["deps"]])) { - return(mddata[["deps"]]) - } - pkgdir <- pkgdir %||% roxy_meta_get("current_package_dir") - deps <- desc::desc_get_deps(pkgdir) - deps <- deps[deps$package != "R", ] - deps <- deps[deps$type %in% c("Depends", "Imports", "Suggests"), ] - deps$package -} diff --git a/tests/testthat/_snaps/markdown-link-resolve.md b/tests/testthat/_snaps/markdown-link-resolve.md new file mode 100644 index 00000000..1027c728 --- /dev/null +++ b/tests/testthat/_snaps/markdown-link-resolve.md @@ -0,0 +1,40 @@ +# resolve_link_package + + Code + resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) + Output + [1] NA + Code + resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) + Output + [1] NA + Code + resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2")) + Output + [1] "cli" + +--- + + Code + resolve_link_package("aa3bc042880aa3b64fef1a9", "roxygen2", test_path( + "testMdLinks2"), list(tag = tag)) + Message + x foo.R:10: @title (automatically generated) Could not resolve link to topic "aa3bc042880aa3b64fef1a9" in the dependencies or base packages. + i If you haven't documented "aa3bc042880aa3b64fef1a9" yet, or just changed its name, this is normal. Once "aa3bc042880aa3b64fef1a9" is documented, this warning goes away. + i Make sure that the name of the topic is spelled correctly. + i Always list the linked package as a dependency. + i Alternatively, you can fully qualify the link with a package name. + Output + [1] NA + +# resolve_link_package name clash + + Code + resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks2"), list( + tag = tag)) + Message + x foo.R:10: @title (automatically generated) Topic "pkg_env" is available in multiple packages: pkgload and rlang. + i Qualify topic explicitly with a package name when linking to it. + Output + [1] NA + diff --git a/tests/testthat/_snaps/markdown-link.md b/tests/testthat/_snaps/markdown-link.md index 12543453..1e5dff8f 100644 --- a/tests/testthat/_snaps/markdown-link.md +++ b/tests/testthat/_snaps/markdown-link.md @@ -19,43 +19,3 @@ Message x :4: @description refers to unavailable topic stringr::bar111. -# resolve_link_package - - Code - resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) - Output - [1] NA - Code - resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) - Output - [1] NA - Code - resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2")) - Output - [1] "cli" - ---- - - Code - resolve_link_package("aa3bc042880aa3b64fef1a9", "roxygen2", test_path( - "testMdLinks2"), list(tag = tag)) - Message - x foo.R:10: @title (automatically generated) Could not resolve link to topic "aa3bc042880aa3b64fef1a9" in the dependencies or base packages. - i If you haven't documented "aa3bc042880aa3b64fef1a9" yet, or just changed its name, this is normal. Once "aa3bc042880aa3b64fef1a9" is documented, this warning goes away. - i Make sure that the name of the topic is spelled correctly. - i Always list the linked package as a dependency. - i Alternatively, you can fully qualify the link with a package name. - Output - [1] NA - -# resolve_link_package name clash - - Code - resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks2"), list( - tag = tag)) - Message - x foo.R:10: @title (automatically generated) Topic "pkg_env" is available in multiple packages: pkgload and rlang. - i Qualify topic explicitly with a package name when linking to it. - Output - [1] NA - diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R new file mode 100644 index 00000000..940e840b --- /dev/null +++ b/tests/testthat/test-markdown-link-resolve.R @@ -0,0 +1,52 @@ +test_that("resolve_link_package", { + # Doesn't work with + skip_if_not( + is.null(.getNamespace("roxygen2")[[".__DEVTOOLS__"]]), + "roxygen2 loaded with devtools" + ) + rm(list = ls(envir = mddata), envir = mddata) + expect_snapshot({ + resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) + resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) + resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2")) + }) + + tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10) + expect_snapshot({ + resolve_link_package( + "aa3bc042880aa3b64fef1a9", + "roxygen2", + test_path("testMdLinks2"), + list(tag = tag) + ) + }) + # re-exported topics are identified + rm(list = ls(envir = mddata), envir = mddata) + expect_equal( + resolve_link_package("process", "testthat", test_path("testMdLinks")), + "processx" + ) +}) + +test_that("resolve_link_package name clash", { + # skip in case pkgload/rlang changes this + skip_on_cran() + tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10) + expect_snapshot({ + resolve_link_package( + "pkg_env", + "roxygen2", + test_path("testMdLinks2"), + list(tag = tag) + ) + }) +}) + +test_that("is_reexported", { + expect_false(is_reexported("process", "processx")) + expect_true(is_reexported("process", "callr")) +}) + +test_that("find_reexport_source", { + expect_equal(find_reexport_source("process", "callr"), "processx") +}) diff --git a/tests/testthat/test-markdown-link.R b/tests/testthat/test-markdown-link.R index 833fb472..aa3461ca 100644 --- a/tests/testthat/test-markdown-link.R +++ b/tests/testthat/test-markdown-link.R @@ -603,55 +603,3 @@ test_that("percents are escaped in link targets", { expect_equivalent_rd(out1, out2) }) -test_that("resolve_link_package", { - # Doesn't work with - skip_if_not( - is.null(.getNamespace("roxygen2")[[".__DEVTOOLS__"]]), - "roxygen2 loaded with devtools" - ) - rm(list = ls(envir = mddata), envir = mddata) - expect_snapshot({ - resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) - resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) - resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2")) - }) - - tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10) - expect_snapshot({ - resolve_link_package( - "aa3bc042880aa3b64fef1a9", - "roxygen2", - test_path("testMdLinks2"), - list(tag = tag) - ) - }) - # re-exported topics are identified - rm(list = ls(envir = mddata), envir = mddata) - expect_equal( - resolve_link_package("process", "testthat", test_path("testMdLinks")), - "processx" - ) -}) - -test_that("resolve_link_package name clash", { - # skip in case pkgload/rlang changes this - skip_on_cran() - tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10) - expect_snapshot({ - resolve_link_package( - "pkg_env", - "roxygen2", - test_path("testMdLinks2"), - list(tag = tag) - ) - }) -}) - -test_that("is_reexported", { - expect_false(is_reexported("process", "processx")) - expect_true(is_reexported("process", "callr")) -}) - -test_that("find_reexport_source", { - expect_equal(find_reexport_source("process", "callr"), "processx") -}) From 46a8a46e6d5411c45d716d0a996a03bd15d18987 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 16:49:49 -0600 Subject: [PATCH 02/25] Drop `mddata` since we never write to it --- R/markdown-link-resolve.R | 6 ------ R/markdown.R | 3 --- tests/testthat/test-markdown-link-resolve.R | 2 -- 3 files changed, 11 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 48bce03c..8ab8ee9e 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -67,13 +67,7 @@ resolve_link_package <- function( NA_character_ } -# Note that this caches the result regardless of -# pkgdir! pkgdir is mainly for testing, in which case you -# need to clear the cache manually. local_pkg_deps <- function(pkgdir = NULL) { - if (!is.null(mddata[["deps"]])) { - return(mddata[["deps"]]) - } pkgdir <- pkgdir %||% roxy_meta_get("current_package_dir") deps <- desc::desc_get_deps(pkgdir) deps <- deps[deps$package != "R", ] diff --git a/R/markdown.R b/R/markdown.R index 70c79a57..93a8d747 100644 --- a/R/markdown.R +++ b/R/markdown.R @@ -17,8 +17,6 @@ markdown <- function(text, tag = NULL, sections = FALSE) { ) } -mddata <- new.env(parent = emptyenv()) - #' Expand the embedded inline code #' #' @details @@ -70,7 +68,6 @@ mddata <- new.env(parent = emptyenv()) #' @keywords internal markdown_pass1 <- function(text) { - rm(list = ls(envir = mddata), envir = mddata) text <- paste(text, collapse = "\n") mdxml <- xml_ns_strip(md_to_mdxml(text, sourcepos = TRUE)) code_nodes <- xml_find_all(mdxml, ".//code | .//code_block") diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 940e840b..3ddb47d2 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -4,7 +4,6 @@ test_that("resolve_link_package", { is.null(.getNamespace("roxygen2")[[".__DEVTOOLS__"]]), "roxygen2 loaded with devtools" ) - rm(list = ls(envir = mddata), envir = mddata) expect_snapshot({ resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) @@ -21,7 +20,6 @@ test_that("resolve_link_package", { ) }) # re-exported topics are identified - rm(list = ls(envir = mddata), envir = mddata) expect_equal( resolve_link_package("process", "testthat", test_path("testMdLinks")), "processx" From 5193eff2ba2314c9cf11bac408a91a0c8dfbe509 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 16:52:37 -0600 Subject: [PATCH 03/25] Move `base_packages()` to `markdown-link-resolve.R` too --- R/markdown-link-resolve.R | 24 ++++++++++++++++++++++++ R/utils.R | 23 ----------------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 8ab8ee9e..be9b2f17 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -75,6 +75,30 @@ local_pkg_deps <- function(pkgdir = NULL) { deps$package } + +base_packages <- function() { + if (getRversion() >= "4.4.0") { + asNamespace("tools")$standard_package_names()[["base"]] + } else { + c( + "base", + "compiler", + "datasets", + "graphics", + "grDevices", + "grid", + "methods", + "parallel", + "splines", + "stats", + "stats4", + "tcltk", + "tools", + "utils" + ) + } +} + # this is mostly from downlit is_exported <- function(name, package) { name %in% getNamespaceExports(ns_env(package)) diff --git a/R/utils.R b/R/utils.R index 9bc067ef..10806ed2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -230,26 +230,3 @@ auto_quote <- function(x) { is_syntactic <- function(x) make.names(x) == x has_quotes <- function(x) str_detect(x, r"[^(`|'|").*\1$]") strip_quotes <- function(x) str_replace(x, r"[^(`|'|")(.*)\1$]", r"(\2)") - -base_packages <- function() { - if (getRversion() >= "4.4.0") { - asNamespace("tools")$standard_package_names()[["base"]] - } else { - c( - "base", - "compiler", - "datasets", - "graphics", - "grDevices", - "grid", - "methods", - "parallel", - "splines", - "stats", - "stats4", - "tcltk", - "tools", - "utils" - ) - } -} From dba7b61a9ef352a0d2eccf2b688506aa572bc7d7 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 16:55:10 -0600 Subject: [PATCH 04/25] Remove unused `is_reexported()` and `is_exported()` --- R/markdown-link-resolve.R | 14 +------------- tests/testthat/test-markdown-link-resolve.R | 5 ----- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index be9b2f17..eaf5f398 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -99,19 +99,7 @@ base_packages <- function() { } } -# this is mostly from downlit -is_exported <- function(name, package) { - name %in% getNamespaceExports(ns_env(package)) -} - -is_reexported <- function(name, package) { - if (package == "base") { - return(FALSE) - } - is_imported <- env_has(ns_imports_env(package), name) - is_imported && is_exported(name, package) -} - +# Copied from downlit:::find_reexport_source find_reexport_source <- function(topic, package) { ns <- ns_env(package) if (!env_has(ns, topic, inherit = TRUE)) { diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 3ddb47d2..5b78db95 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -40,11 +40,6 @@ test_that("resolve_link_package name clash", { }) }) -test_that("is_reexported", { - expect_false(is_reexported("process", "processx")) - expect_true(is_reexported("process", "callr")) -}) - test_that("find_reexport_source", { expect_equal(find_reexport_source("process", "callr"), "processx") }) From 52edfa66ed950c314c08d5dcd909183f9305b6bf Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 16:59:48 -0600 Subject: [PATCH 05/25] Simplify `find_reexport_source()` --- R/markdown-link-resolve.R | 13 ++++++++----- tests/testthat/test-markdown-link-resolve.R | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index eaf5f398..5657754c 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -20,8 +20,7 @@ resolve_link_package <- function( pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, function(p) { - p0 <- find_reexport_source(topic, p) - if (is.na(p0)) p else p0 + find_reexport_source(topic, p) %||% p }) pkg_has_topic <- unique(pkg_has_topic) base <- base_packages() @@ -99,11 +98,15 @@ base_packages <- function() { } } -# Copied from downlit:::find_reexport_source +# Adapted from downlit:::find_reexport_source find_reexport_source <- function(topic, package) { + if (package %in% base_packages()) { + return(NULL) + } + ns <- ns_env(package) if (!env_has(ns, topic, inherit = TRUE)) { - return(NA_character_) + return(NULL) } obj <- env_get(ns, topic, inherit = TRUE) @@ -123,7 +126,7 @@ find_reexport_source <- function(topic, package) { wpkgs <- vapply(imp, `%in%`, x = topic, FUN.VALUE = logical(1)) if (!any(wpkgs)) { - return(NA_character_) + return(NULL) } pkgs <- names(wpkgs)[wpkgs] # Take the last match, in case imports have name clashes. diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 5b78db95..87e05293 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -42,4 +42,5 @@ test_that("resolve_link_package name clash", { test_that("find_reexport_source", { expect_equal(find_reexport_source("process", "callr"), "processx") + expect_null(find_reexport_source("list", "base")) }) From 9a9c2ac94062aa25224d8264a6edbfb7f8dc9d5e Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 17:29:57 -0600 Subject: [PATCH 06/25] Simplify tests/implementation --- R/markdown-link-resolve.R | 35 ++++------ R/markdown-link.R | 2 +- .../testthat/_snaps/markdown-link-resolve.md | 31 ++------- tests/testthat/test-markdown-link-resolve.R | 55 ++++++++-------- tests/testthat/testMdLinks/DESCRIPTION | 65 +++---------------- tests/testthat/testMdLinks2/DESCRIPTION | 56 ---------------- 6 files changed, 56 insertions(+), 188 deletions(-) delete mode 100644 tests/testthat/testMdLinks2/DESCRIPTION diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 5657754c..6a5f433c 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -2,39 +2,29 @@ resolve_link_package <- function( topic, me = NULL, pkgdir = NULL, - state = NULL + tag = roxy_tag("unknown", "") ) { me <- me %||% roxy_meta_get("current_package") - # this is from the roxygen2 tests, should not happen on a real package - if (is.null(me) || is.na(me) || me == "") { - return(NA_character_) - } # if it is in the current package, then no need for package name, right? - if (has_topic(topic, me)) { + if (!is.null(me) && has_topic(topic, me)) { return(NA_character_) } - # try packages in depends, imports, suggests first, error on name clashes + # first look in Depends/Imports/Suggests pkgs <- local_pkg_deps(pkgdir) - pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, function(p) { find_reexport_source(topic, p) %||% p }) pkg_has_topic <- unique(pkg_has_topic) - base <- base_packages() - if (length(pkg_has_topic) == 0) { - # fall through to check base packages as well - } else if (length(pkg_has_topic) == 1) { - if (pkg_has_topic %in% base) { - return(NA_character_) - } else { - return(pkg_has_topic) - } - } else { + if (length(pkg_has_topic) == 1) { + return(pkg_has_topic) + } + + if (length(pkg_has_topic) > 1) { warn_roxy_tag( - state$tag, + tag, c( "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg_has_topic}}", i = "Qualify topic explicitly with a package name when linking to it." @@ -43,14 +33,14 @@ resolve_link_package <- function( return(NA_character_) } - # try base packages as well, take the first hit, - # there should not be any name clashes, anyway + # then try base packages, taking the first hit since there shouldn't be name clashes + base <- base_packages() for (bp in base) { if (has_topic(topic, bp)) return(NA_character_) } warn_roxy_tag( - state$tag, + tag, c( "Could not resolve link to topic {.val {topic}} in the dependencies or base packages", "i" = paste( @@ -74,7 +64,6 @@ local_pkg_deps <- function(pkgdir = NULL) { deps$package } - base_packages <- function() { if (getRversion() >= "4.4.0") { asNamespace("tools")$standard_package_names()[["base"]] diff --git a/R/markdown-link.R b/R/markdown-link.R index 98fb4284..7aeff6cf 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -140,7 +140,7 @@ parse_link <- function(destination, contents, state) { noclass <- str_match(fun, "^(.*)-class$")[1, 2] if (is.na(pkg)) { - pkg <- resolve_link_package(obj, thispkg, state = state) + pkg <- resolve_link_package(obj, thispkg, tag = state$tag) } if (!is.na(pkg) && pkg == thispkg) { pkg <- NA_character_ diff --git a/tests/testthat/_snaps/markdown-link-resolve.md b/tests/testthat/_snaps/markdown-link-resolve.md index 1027c728..aafbbaee 100644 --- a/tests/testthat/_snaps/markdown-link-resolve.md +++ b/tests/testthat/_snaps/markdown-link-resolve.md @@ -1,39 +1,22 @@ -# resolve_link_package +# gives useful warning if no topic found Code - resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) - Output - [1] NA - Code - resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) - Output - [1] NA - Code - resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2")) - Output - [1] "cli" - ---- - - Code - resolve_link_package("aa3bc042880aa3b64fef1a9", "roxygen2", test_path( - "testMdLinks2"), list(tag = tag)) + resolve_link_package("doesntexist", "roxygen2", test_path("testMdLinks")) Message - x foo.R:10: @title (automatically generated) Could not resolve link to topic "aa3bc042880aa3b64fef1a9" in the dependencies or base packages. - i If you haven't documented "aa3bc042880aa3b64fef1a9" yet, or just changed its name, this is normal. Once "aa3bc042880aa3b64fef1a9" is documented, this warning goes away. + x NA:NA: @unknown Could not resolve link to topic "doesntexist" in the dependencies or base packages. + i If you haven't documented "doesntexist" yet, or just changed its name, this is normal. Once "doesntexist" is documented, this warning goes away. i Make sure that the name of the topic is spelled correctly. i Always list the linked package as a dependency. i Alternatively, you can fully qualify the link with a package name. Output [1] NA -# resolve_link_package name clash +# gives useful warning if same name in multiple packages Code - resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks2"), list( - tag = tag)) + resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks")) Message - x foo.R:10: @title (automatically generated) Topic "pkg_env" is available in multiple packages: pkgload and rlang. + x NA:NA: @unknown Topic "pkg_env" is available in multiple packages: pkgload and rlang. i Qualify topic explicitly with a package name when linking to it. Output [1] NA diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 87e05293..c96bb515 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -1,42 +1,43 @@ -test_that("resolve_link_package", { - # Doesn't work with - skip_if_not( - is.null(.getNamespace("roxygen2")[[".__DEVTOOLS__"]]), - "roxygen2 loaded with devtools" +test_that("imported functions qualified with package name", { + expect_equal( + resolve_link_package("cli_abort", pkgdir = test_path("testMdLinks")), + "cli" ) - expect_snapshot({ - resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2")) - resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2")) - resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2")) - }) +}) + +test_that("base functions don't need qualification", { + # base packages don't need qualification + expect_equal( + resolve_link_package("mean", pkgdir = test_path("testMdLinks")), + NA_character_ + ) +}) - tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10) +test_that("topics in current package don't need qualification", { + expect_equal( + resolve_link_package("cli_abort", "cli", pkgdir = test_path("testMdLinks")), + NA_character_ + ) +}) + +test_that("gives useful warning if no topic found", { expect_snapshot({ - resolve_link_package( - "aa3bc042880aa3b64fef1a9", - "roxygen2", - test_path("testMdLinks2"), - list(tag = tag) - ) + resolve_link_package("doesntexist", "roxygen2", test_path("testMdLinks")) }) - # re-exported topics are identified +}) + +test_that("re-exported topics are identified", { expect_equal( - resolve_link_package("process", "testthat", test_path("testMdLinks")), + resolve_link_package("process", pkgdir = test_path("testMdLinks")), "processx" ) }) -test_that("resolve_link_package name clash", { +test_that("gives useful warning if same name in multiple packages", { # skip in case pkgload/rlang changes this skip_on_cran() - tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10) expect_snapshot({ - resolve_link_package( - "pkg_env", - "roxygen2", - test_path("testMdLinks2"), - list(tag = tag) - ) + resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks")) }) }) diff --git a/tests/testthat/testMdLinks/DESCRIPTION b/tests/testthat/testMdLinks/DESCRIPTION index 0d38b02d..0cead249 100644 --- a/tests/testthat/testMdLinks/DESCRIPTION +++ b/tests/testthat/testMdLinks/DESCRIPTION @@ -1,58 +1,9 @@ -Package: testthat -Title: Unit Testing for R -Version: 3.2.1.9000 -Authors@R: c( - person("Hadley", "Wickham", , "hadley@posit.co", role = c("aut", "cre")), - person("Posit Software, PBC", role = c("cph", "fnd")), - person("R Core team", role = "ctb", - comment = "Implementation of utils::recover()") - ) -Description: Software testing is important, but, in part because it is - frustrating and boring, many of us avoid it. 'testthat' is a testing - framework for R that is easy to learn and use, and integrates with - your existing 'workflow'. -License: MIT + file LICENSE -URL: https://testthat.r-lib.org, https://github.com/r-lib/testthat -BugReports: https://github.com/r-lib/testthat/issues -Depends: - R (>= 3.6.0) +Package: testMdLinks +Title: Dummy package for testing markdown link resolution +Version: 1.0.0 Imports: - brio (>= 1.1.3), - callr (>= 3.7.3), - cli (>= 3.6.1), - desc (>= 1.4.2), - digest (>= 0.6.33), - evaluate (>= 0.21), - jsonlite (>= 1.8.7), - lifecycle (>= 1.0.3), - magrittr (>= 2.0.3), - methods, - pkgload (>= 1.3.2.1), - praise (>= 1.0.0), - processx (>= 3.8.2), - ps (>= 1.7.5), - R6 (>= 2.5.1), - rlang (>= 1.1.1), - utils, - waldo (>= 0.5.1), - withr (>= 2.5.0) -Suggests: - covr, - curl (>= 0.9.5), - diffviewer (>= 0.1.0), - knitr, - rmarkdown, - rstudioapi, - shiny, - usethis, - vctrs (>= 0.1.0), - xml2 -VignetteBuilder: - knitr -Config/Needs/website: tidyverse/tidytemplate -Config/testthat/edition: 3 -Config/testthat/parallel: true -Config/testthat/start-first: watcher, parallel* -Encoding: UTF-8 -Roxygen: list(markdown = TRUE, r6 = FALSE) -RoxygenNote: 7.2.3 + callr, + processx, + cli, + pkgload, + rlang diff --git a/tests/testthat/testMdLinks2/DESCRIPTION b/tests/testthat/testMdLinks2/DESCRIPTION deleted file mode 100644 index d37a0f02..00000000 --- a/tests/testthat/testMdLinks2/DESCRIPTION +++ /dev/null @@ -1,56 +0,0 @@ -Package: roxygen2 -Title: In-Line Documentation for R -Version: 7.3.2.9000 -Authors@R: c( - person("Hadley", "Wickham", , "hadley@posit.co", role = c("aut", "cre", "cph"), - comment = c(ORCID = "0000-0003-4757-117X")), - person("Peter", "Danenberg", , "pcd@roxygen.org", role = c("aut", "cph")), - person("Gábor", "Csárdi", , "csardi.gabor@gmail.com", role = "aut"), - person("Manuel", "Eugster", role = c("aut", "cph")), - person("Posit Software, PBC", role = c("cph", "fnd")) - ) -Description: Generate your Rd documentation, 'NAMESPACE' file, and - collation field using specially formatted comments. Writing - documentation in-line with code makes it easier to keep your - documentation up-to-date as your requirements change. 'roxygen2' is - inspired by the 'Doxygen' system for C++. -License: MIT + file LICENSE -URL: https://roxygen2.r-lib.org/, https://github.com/r-lib/roxygen2 -BugReports: https://github.com/r-lib/roxygen2/issues -Depends: - R (>= 3.6) -Imports: - brew, - cli (>= 3.3.0), - commonmark, - desc (>= 1.2.0), - knitr, - methods, - pkgload (>= 1.0.2), - purrr (>= 1.0.0), - R6 (>= 2.1.2), - rlang (>= 1.0.6), - stringi, - stringr (>= 1.0.0), - utils, - withr, - xml2 -Suggests: - covr, - R.methodsS3, - R.oo, - rmarkdown (>= 2.16), - testthat (>= 3.1.2), - yaml -LinkingTo: - cpp11 -VignetteBuilder: - knitr -Config/Needs/development: testthat -Config/Needs/website: tidyverse/tidytemplate -Config/testthat/edition: 3 -Config/testthat/parallel: TRUE -Encoding: UTF-8 -Language: en-GB -Roxygen: list(markdown = TRUE, load = "installed") -RoxygenNote: 7.3.2.9000 From 5eeaeca76a78875d9b235f08fa035f1b70dbeac2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 17:36:50 -0600 Subject: [PATCH 07/25] Extract out cacheable `find_topic_package()` --- R/markdown-link-resolve.R | 78 +++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 6a5f433c..f7d0cf9b 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -3,10 +3,52 @@ resolve_link_package <- function( me = NULL, pkgdir = NULL, tag = roxy_tag("unknown", "") +) { + pkg <- find_topic_package(topic, me = me, pkgdir = pkgdir) + + if (length(pkg) == 0) { + warn_roxy_tag( + tag, + c( + "Could not resolve link to topic {.val {topic}} in the dependencies or base packages", + "i" = paste( + "If you haven't documented {.val {topic}} yet, or just changed its name, this is normal.", + "Once {.val {topic}} is documented, this warning goes away." + ), + "i" = "Make sure that the name of the topic is spelled correctly.", + "i" = "Always list the linked package as a dependency.", + "i" = "Alternatively, you can fully qualify the link with a package name." + ) + ) + NA_character_ + } else if (length(pkg) == 1) { + pkg + } else { + warn_roxy_tag( + tag, + c( + "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg}}", + i = "Qualify topic explicitly with a package name when linking to it." + ) + ) + return(NA_character_) + } +} + +# Returns: +# - NA_character_: topic found in current package or base packages (no +# qualification needed) +# - A single package name: topic found in exactly one dependency +# - A vector of package names: topic found in multiple dependencies +# - character(): topic not found anywhere +find_topic_package <- function( + topic, + me = NULL, + pkgdir = NULL ) { me <- me %||% roxy_meta_get("current_package") - # if it is in the current package, then no need for package name, right? + # if it is in the current package, then no need for package name if (!is.null(me) && has_topic(topic, me)) { return(NA_character_) } @@ -18,42 +60,16 @@ resolve_link_package <- function( find_reexport_source(topic, p) %||% p }) pkg_has_topic <- unique(pkg_has_topic) - if (length(pkg_has_topic) == 1) { + if (length(pkg_has_topic) >= 1) { return(pkg_has_topic) } - if (length(pkg_has_topic) > 1) { - warn_roxy_tag( - tag, - c( - "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg_has_topic}}", - i = "Qualify topic explicitly with a package name when linking to it." - ) - ) - return(NA_character_) - } - - # then try base packages, taking the first hit since there shouldn't be name clashes - base <- base_packages() - for (bp in base) { + # then try base packages + for (bp in base_packages()) { if (has_topic(topic, bp)) return(NA_character_) } - warn_roxy_tag( - tag, - c( - "Could not resolve link to topic {.val {topic}} in the dependencies or base packages", - "i" = paste( - "If you haven't documented {.val {topic}} yet, or just changed its name, this is normal.", - "Once {.val {topic}} is documented, this warning goes away." - ), - "i" = "Make sure that the name of the topic is spelled correctly.", - "i" = "Always list the linked package as a dependency.", - "i" = "Alternatively, you can fully qualify the link with a package name." - ) - ) - - NA_character_ + character() } local_pkg_deps <- function(pkgdir = NULL) { From 5c68699ed93bb29d3ced8539b20be7b712eb1349 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 17:49:07 -0600 Subject: [PATCH 08/25] Further streamline function for testing --- R/markdown-link-resolve.R | 25 +++----- R/markdown-link.R | 5 +- .../testthat/_snaps/markdown-link-resolve.md | 6 +- tests/testthat/test-markdown-link-resolve.R | 59 ++++++++++--------- 4 files changed, 45 insertions(+), 50 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index f7d0cf9b..850d24b7 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -1,10 +1,9 @@ -resolve_link_package <- function( - topic, - me = NULL, - pkgdir = NULL, - tag = roxy_tag("unknown", "") -) { - pkg <- find_topic_package(topic, me = me, pkgdir = pkgdir) +resolve_link_package <- function(topic, tag = NULL) { + pkg <- roxy_meta_get("current_package") + pkg_dir <- roxy_meta_get("current_package_dir") + tag <- tag %||% roxy_tag("unknown", "") # only for tests + + pkg <- find_topic_package(topic, pkg = pkg, pkg_dir = pkg_dir) if (length(pkg) == 0) { warn_roxy_tag( @@ -41,20 +40,14 @@ resolve_link_package <- function( # - A single package name: topic found in exactly one dependency # - A vector of package names: topic found in multiple dependencies # - character(): topic not found anywhere -find_topic_package <- function( - topic, - me = NULL, - pkgdir = NULL -) { - me <- me %||% roxy_meta_get("current_package") - +find_topic_package <- function(topic, pkg, pkg_dir) { # if it is in the current package, then no need for package name - if (!is.null(me) && has_topic(topic, me)) { + if (has_topic(topic, pkg)) { return(NA_character_) } # first look in Depends/Imports/Suggests - pkgs <- local_pkg_deps(pkgdir) + pkgs <- local_pkg_deps(pkg_dir) pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, function(p) { find_reexport_source(topic, p) %||% p diff --git a/R/markdown-link.R b/R/markdown-link.R index 7aeff6cf..eef367aa 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -140,9 +140,8 @@ parse_link <- function(destination, contents, state) { noclass <- str_match(fun, "^(.*)-class$")[1, 2] if (is.na(pkg)) { - pkg <- resolve_link_package(obj, thispkg, tag = state$tag) - } - if (!is.na(pkg) && pkg == thispkg) { + pkg <- resolve_link_package(obj, tag = state$tag) + } else if (!is.na(pkg) && pkg == thispkg) { pkg <- NA_character_ } file <- find_topic_filename(pkg, obj, state$tag) diff --git a/tests/testthat/_snaps/markdown-link-resolve.md b/tests/testthat/_snaps/markdown-link-resolve.md index aafbbaee..396e72ab 100644 --- a/tests/testthat/_snaps/markdown-link-resolve.md +++ b/tests/testthat/_snaps/markdown-link-resolve.md @@ -1,7 +1,7 @@ -# gives useful warning if no topic found +# useful warning if no topic found Code - resolve_link_package("doesntexist", "roxygen2", test_path("testMdLinks")) + resolve_link_package("doesntexist") Message x NA:NA: @unknown Could not resolve link to topic "doesntexist" in the dependencies or base packages. i If you haven't documented "doesntexist" yet, or just changed its name, this is normal. Once "doesntexist" is documented, this warning goes away. @@ -14,7 +14,7 @@ # gives useful warning if same name in multiple packages Code - resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks")) + resolve_link_package("pkg_env") Message x NA:NA: @unknown Topic "pkg_env" is available in multiple packages: pkgload and rlang. i Qualify topic explicitly with a package name when linking to it. diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index c96bb515..3ec80e68 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -1,44 +1,47 @@ +test_that("topics in current package don't need qualification", { + local_roxy_meta_set("current_package", "cli") + expect_equal(resolve_link_package("cli_abort"), NA_character_) +}) + test_that("imported functions qualified with package name", { - expect_equal( - resolve_link_package("cli_abort", pkgdir = test_path("testMdLinks")), - "cli" - ) + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + expect_equal(resolve_link_package("cli_abort"), "cli") }) test_that("base functions don't need qualification", { - # base packages don't need qualification - expect_equal( - resolve_link_package("mean", pkgdir = test_path("testMdLinks")), - NA_character_ - ) -}) + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) -test_that("topics in current package don't need qualification", { - expect_equal( - resolve_link_package("cli_abort", "cli", pkgdir = test_path("testMdLinks")), - NA_character_ - ) + expect_equal(resolve_link_package("mean"), NA_character_) }) -test_that("gives useful warning if no topic found", { - expect_snapshot({ - resolve_link_package("doesntexist", "roxygen2", test_path("testMdLinks")) - }) +test_that("useful warning if no topic found", { + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + expect_snapshot(resolve_link_package("doesntexist")) }) test_that("re-exported topics are identified", { - expect_equal( - resolve_link_package("process", pkgdir = test_path("testMdLinks")), - "processx" - ) + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + expect_equal(resolve_link_package("process"), "processx") }) test_that("gives useful warning if same name in multiple packages", { - # skip in case pkgload/rlang changes this - skip_on_cran() - expect_snapshot({ - resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks")) - }) + skip_on_cran() # in case pkgload/rlang changes this + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + expect_equal( + find_topic_package("pkg_env", "testMdLinks", test_path("testMdLinks")), + c("pkgload", "rlang") + ) + + expect_snapshot(resolve_link_package("pkg_env")) }) test_that("find_reexport_source", { From 81db85548cc4e4fbb15f1f65e9ac1c25f03a579e Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 17:53:31 -0600 Subject: [PATCH 09/25] Fix markdown-link test failures --- R/markdown-link-resolve.R | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 850d24b7..7a534005 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -1,6 +1,10 @@ resolve_link_package <- function(topic, tag = NULL) { pkg <- roxy_meta_get("current_package") pkg_dir <- roxy_meta_get("current_package_dir") + if (is.null(pkg) || is.null(pkg_dir)) { + # Don't try and link in basic tests + return(NA_character_) + } tag <- tag %||% roxy_tag("unknown", "") # only for tests pkg <- find_topic_package(topic, pkg = pkg, pkg_dir = pkg_dir) From 8abdf082458ac83c470c4a3eae950b9efa48e828 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 17:58:03 -0600 Subject: [PATCH 10/25] More polishing --- R/markdown-link-resolve.R | 20 +++++++++----------- tests/testthat/test-markdown-link-resolve.R | 8 ++++++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 7a534005..74fbc5be 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -1,10 +1,10 @@ resolve_link_package <- function(topic, tag = NULL) { pkg <- roxy_meta_get("current_package") - pkg_dir <- roxy_meta_get("current_package_dir") - if (is.null(pkg) || is.null(pkg_dir)) { + if (is.null(pkg)) { # Don't try and link in basic tests return(NA_character_) } + pkg_dir <- roxy_meta_get("current_package_dir") tag <- tag %||% roxy_tag("unknown", "") # only for tests pkg <- find_topic_package(topic, pkg = pkg, pkg_dir = pkg_dir) @@ -51,11 +51,9 @@ find_topic_package <- function(topic, pkg, pkg_dir) { } # first look in Depends/Imports/Suggests - pkgs <- local_pkg_deps(pkg_dir) + pkgs <- pkg_deps(pkg_dir) pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] - pkg_has_topic <- map_chr(pkg_has_topic, function(p) { - find_reexport_source(topic, p) %||% p - }) + pkg_has_topic <- map_chr(pkg_has_topic, \(pkg) find_source(topic, pkg)) pkg_has_topic <- unique(pkg_has_topic) if (length(pkg_has_topic) >= 1) { return(pkg_has_topic) @@ -69,7 +67,7 @@ find_topic_package <- function(topic, pkg, pkg_dir) { character() } -local_pkg_deps <- function(pkgdir = NULL) { +pkg_deps <- function(pkgdir = NULL) { pkgdir <- pkgdir %||% roxy_meta_get("current_package_dir") deps <- desc::desc_get_deps(pkgdir) deps <- deps[deps$package != "R", ] @@ -101,14 +99,14 @@ base_packages <- function() { } # Adapted from downlit:::find_reexport_source -find_reexport_source <- function(topic, package) { +find_source <- function(topic, package) { if (package %in% base_packages()) { - return(NULL) + return(package) } ns <- ns_env(package) if (!env_has(ns, topic, inherit = TRUE)) { - return(NULL) + return(package) } obj <- env_get(ns, topic, inherit = TRUE) @@ -128,7 +126,7 @@ find_reexport_source <- function(topic, package) { wpkgs <- vapply(imp, `%in%`, x = topic, FUN.VALUE = logical(1)) if (!any(wpkgs)) { - return(NULL) + return(package) } pkgs <- names(wpkgs)[wpkgs] # Take the last match, in case imports have name clashes. diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 3ec80e68..2235ac64 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -1,3 +1,7 @@ +test_that("don't resolve if current_package not set", { + expect_equal(resolve_link_package("cli_abort"), NA_character_) +}) + test_that("topics in current package don't need qualification", { local_roxy_meta_set("current_package", "cli") expect_equal(resolve_link_package("cli_abort"), NA_character_) @@ -45,6 +49,6 @@ test_that("gives useful warning if same name in multiple packages", { }) test_that("find_reexport_source", { - expect_equal(find_reexport_source("process", "callr"), "processx") - expect_null(find_reexport_source("list", "base")) + expect_equal(find_source("process", "callr"), "processx") + expect_equal(find_source("list", "base"), "base") }) From 713526537a23f472da4c09649790e2e7521aecce Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 18:02:34 -0600 Subject: [PATCH 11/25] Complete test coverage --- tests/testthat/test-markdown-link-resolve.R | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 2235ac64..490f9814 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -48,7 +48,21 @@ test_that("gives useful warning if same name in multiple packages", { expect_snapshot(resolve_link_package("pkg_env")) }) -test_that("find_reexport_source", { - expect_equal(find_source("process", "callr"), "processx") +test_that("find_source returns base package as-is", { + skip_on_cran() # since depends on other packages + expect_equal(find_source("list", "base"), "base") + # topic not in namespace + expect_equal(find_source("doesnt'exist", "cli"), "cli") + # primitive objects are always in base + expect_equal(find_source("is_null", "rlang"), "base") + # callr re-exports process from processx + expect_equal(find_source("process", "callr"), "processx") +}) + +test_that("find_source traces re-exported non-function to source package", { + # .data is a non-function object exported by rlang, re-exported by others + skip_on_cran() + skip_if_not_installed("tidyselect") + expect_equal(find_source(".data", "tidyselect"), "rlang") }) From 533b4c8a786d7ec2e13cf8518a405aa747e691fc Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 18:09:16 -0600 Subject: [PATCH 12/25] Never qualify base packges --- R/markdown-link-resolve.R | 12 ++++++++++-- tests/testthat/test-markdown-link-resolve.R | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 74fbc5be..bbdea4fb 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -55,12 +55,20 @@ find_topic_package <- function(topic, pkg, pkg_dir) { pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, \(pkg) find_source(topic, pkg)) pkg_has_topic <- unique(pkg_has_topic) - if (length(pkg_has_topic) >= 1) { + base <- base_packages() + pkg_has_topic <- setdiff(pkg_has_topic, base) + if (length(pkg_has_topic) == 1) { + if (pkg_has_topic %in% base) { + return(NA_character_) + } else { + return(pkg_has_topic) + } + } else if (length(pkg_has_topic) > 1) { return(pkg_has_topic) } # then try base packages - for (bp in base_packages()) { + for (bp in base) { if (has_topic(topic, bp)) return(NA_character_) } diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 490f9814..90651873 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -21,6 +21,13 @@ test_that("base functions don't need qualification", { expect_equal(resolve_link_package("mean"), NA_character_) }) +test_that("base functions re-exported by deps don't need qualification", { + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + expect_equal(resolve_link_package("is.null"), NA_character_) +}) + test_that("useful warning if no topic found", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) From 1d07bbf5d9f1bd07a3e58f7684f77f2277cdb312 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 20:32:38 -0600 Subject: [PATCH 13/25] Add basic caching mechanism --- R/markdown-link-resolve.R | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index bbdea4fb..1d15403c 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -7,7 +7,7 @@ resolve_link_package <- function(topic, tag = NULL) { pkg_dir <- roxy_meta_get("current_package_dir") tag <- tag %||% roxy_tag("unknown", "") # only for tests - pkg <- find_topic_package(topic, pkg = pkg, pkg_dir = pkg_dir) + pkg <- find_topic_package_cached(topic, pkg = pkg, pkg_dir = pkg_dir) if (length(pkg) == 0) { warn_roxy_tag( @@ -38,6 +38,13 @@ resolve_link_package <- function(topic, tag = NULL) { } } +topic_package_cache <- new_environment() + +find_topic_package_cached <- function(topic, pkg, pkg_dir) { + key <- paste0(pkg, "::", topic) + env_cache(topic_package_cache, key, find_topic_package(topic, pkg, pkg_dir)) +} + # Returns: # - NA_character_: topic found in current package or base packages (no # qualification needed) From 24885641bdf361a7e353a7785b1984d6204a5645 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 3 Mar 2026 20:33:13 -0600 Subject: [PATCH 14/25] Add news bullet --- NEWS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.md b/NEWS.md index 9fa89bfa..23507b28 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # roxygen2 (development version) +* Markdown link resolution is now cached, fixing a performance regression when documenting packages with many cross-package links (#1724). * Markdown link text now supports non-code markup like bold and italic, e.g., `[*italic text*][func]` generates `\link[=func]{\emph{italic text}}`, matching R's support for markup in `\link` text in R 4.5.0. * `object_format()` now escapes braces in class names, fixing broken Rd output for data objects with class `{` like `quote({})` (#1744). * roxygen2 no longer depends on purrr. From 54b5c13c687de7da208fb8d8b31ccb3489c46636 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Wed, 4 Mar 2026 07:28:07 -0600 Subject: [PATCH 15/25] Polishing --- R/markdown-link-resolve.R | 59 +++++++++---------- R/markdown-link.R | 2 +- .../testthat/_snaps/markdown-link-resolve.md | 4 +- tests/testthat/test-markdown-link-resolve.R | 18 +++--- 4 files changed, 39 insertions(+), 44 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 1d15403c..7732fd08 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -1,4 +1,4 @@ -resolve_link_package <- function(topic, tag = NULL) { +find_package <- function(topic, tag = NULL) { pkg <- roxy_meta_get("current_package") if (is.null(pkg)) { # Don't try and link in basic tests @@ -7,7 +7,7 @@ resolve_link_package <- function(topic, tag = NULL) { pkg_dir <- roxy_meta_get("current_package_dir") tag <- tag %||% roxy_tag("unknown", "") # only for tests - pkg <- find_topic_package_cached(topic, pkg = pkg, pkg_dir = pkg_dir) + pkg <- find_package_cached(topic, pkg = pkg, pkg_dir = pkg_dir) if (length(pkg) == 0) { warn_roxy_tag( @@ -34,56 +34,51 @@ resolve_link_package <- function(topic, tag = NULL) { i = "Qualify topic explicitly with a package name when linking to it." ) ) - return(NA_character_) + NA_character_ } } -topic_package_cache <- new_environment() - -find_topic_package_cached <- function(topic, pkg, pkg_dir) { +find_package_cache <- new_environment() +find_package_cached <- function(topic, pkg, pkg_dir) { key <- paste0(pkg, "::", topic) - env_cache(topic_package_cache, key, find_topic_package(topic, pkg, pkg_dir)) + env_cache(find_package_cache, key, find_package_lookup(topic, pkg, pkg_dir)) } -# Returns: -# - NA_character_: topic found in current package or base packages (no -# qualification needed) -# - A single package name: topic found in exactly one dependency -# - A vector of package names: topic found in multiple dependencies -# - character(): topic not found anywhere -find_topic_package <- function(topic, pkg, pkg_dir) { +# NA_character() = found, doesn't need qualification +# character(0) = not found +# character(1) = one match +# character(>1) = multiple matches +find_package_lookup <- function(topic, pkg, pkg_dir) { # if it is in the current package, then no need for package name if (has_topic(topic, pkg)) { return(NA_character_) } - # first look in Depends/Imports/Suggests pkgs <- pkg_deps(pkg_dir) pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, \(pkg) find_source(topic, pkg)) pkg_has_topic <- unique(pkg_has_topic) + base <- base_packages() - pkg_has_topic <- setdiff(pkg_has_topic, base) - if (length(pkg_has_topic) == 1) { + if (length(pkg_has_topic) == 0) { + # no matches, so try base packages + for (bp in base) { + if (has_topic(topic, bp)) return(NA_character_) + } + character() + } else if (length(pkg_has_topic) == 1) { if (pkg_has_topic %in% base) { - return(NA_character_) + # never qualify links to base packages + NA_character_ } else { - return(pkg_has_topic) + pkg_has_topic } - } else if (length(pkg_has_topic) > 1) { - return(pkg_has_topic) - } - - # then try base packages - for (bp in base) { - if (has_topic(topic, bp)) return(NA_character_) + } else { + pkg_has_topic } - - character() } -pkg_deps <- function(pkgdir = NULL) { - pkgdir <- pkgdir %||% roxy_meta_get("current_package_dir") +pkg_deps <- function(pkgdir) { deps <- desc::desc_get_deps(pkgdir) deps <- deps[deps$package != "R", ] deps <- deps[deps$type %in% c("Depends", "Imports", "Suggests"), ] @@ -138,11 +133,11 @@ find_source <- function(topic, package) { ## entry that contains `topic`. imp <- getNamespaceImports(ns) imp <- imp[names(imp) != ""] - wpkgs <- vapply(imp, `%in%`, x = topic, FUN.VALUE = logical(1)) - + wpkgs <- map_lgl(imp, `%in%`, x = topic) if (!any(wpkgs)) { return(package) } + pkgs <- names(wpkgs)[wpkgs] # Take the last match, in case imports have name clashes. pkgs[[length(pkgs)]] diff --git a/R/markdown-link.R b/R/markdown-link.R index eef367aa..62a28e7f 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -140,7 +140,7 @@ parse_link <- function(destination, contents, state) { noclass <- str_match(fun, "^(.*)-class$")[1, 2] if (is.na(pkg)) { - pkg <- resolve_link_package(obj, tag = state$tag) + pkg <- find_package(obj, tag = state$tag) } else if (!is.na(pkg) && pkg == thispkg) { pkg <- NA_character_ } diff --git a/tests/testthat/_snaps/markdown-link-resolve.md b/tests/testthat/_snaps/markdown-link-resolve.md index 396e72ab..1dbe477d 100644 --- a/tests/testthat/_snaps/markdown-link-resolve.md +++ b/tests/testthat/_snaps/markdown-link-resolve.md @@ -1,7 +1,7 @@ # useful warning if no topic found Code - resolve_link_package("doesntexist") + find_package("doesntexist") Message x NA:NA: @unknown Could not resolve link to topic "doesntexist" in the dependencies or base packages. i If you haven't documented "doesntexist" yet, or just changed its name, this is normal. Once "doesntexist" is documented, this warning goes away. @@ -14,7 +14,7 @@ # gives useful warning if same name in multiple packages Code - resolve_link_package("pkg_env") + find_package("pkg_env") Message x NA:NA: @unknown Topic "pkg_env" is available in multiple packages: pkgload and rlang. i Qualify topic explicitly with a package name when linking to it. diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 90651873..7159202d 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -1,45 +1,45 @@ test_that("don't resolve if current_package not set", { - expect_equal(resolve_link_package("cli_abort"), NA_character_) + expect_equal(find_package("cli_abort"), NA_character_) }) test_that("topics in current package don't need qualification", { local_roxy_meta_set("current_package", "cli") - expect_equal(resolve_link_package("cli_abort"), NA_character_) + expect_equal(find_package("cli_abort"), NA_character_) }) test_that("imported functions qualified with package name", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_equal(resolve_link_package("cli_abort"), "cli") + expect_equal(find_package("cli_abort"), "cli") }) test_that("base functions don't need qualification", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_equal(resolve_link_package("mean"), NA_character_) + expect_equal(find_package("mean"), NA_character_) }) test_that("base functions re-exported by deps don't need qualification", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_equal(resolve_link_package("is.null"), NA_character_) + expect_equal(find_package("is.null"), NA_character_) }) test_that("useful warning if no topic found", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_snapshot(resolve_link_package("doesntexist")) + expect_snapshot(find_package("doesntexist")) }) test_that("re-exported topics are identified", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_equal(resolve_link_package("process"), "processx") + expect_equal(find_package("process"), "processx") }) test_that("gives useful warning if same name in multiple packages", { @@ -48,11 +48,11 @@ test_that("gives useful warning if same name in multiple packages", { local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) expect_equal( - find_topic_package("pkg_env", "testMdLinks", test_path("testMdLinks")), + find_package_lookup("pkg_env", "testMdLinks", test_path("testMdLinks")), c("pkgload", "rlang") ) - expect_snapshot(resolve_link_package("pkg_env")) + expect_snapshot(find_package("pkg_env")) }) test_that("find_source returns base package as-is", { From 16e36e9b57c89af174f2a5660ee9c732b369b6a1 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Wed, 4 Mar 2026 11:15:06 -0600 Subject: [PATCH 16/25] Treat base packages like others --- NEWS.md | 1 + R/markdown-link-resolve.R | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index 23507b28..14ef7616 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # roxygen2 (development version) * Markdown link resolution is now cached, fixing a performance regression when documenting packages with many cross-package links (#1724). +* Markdown link resolution includes base packages in the ambiguity resolution detection, so they will no longer silently match a non-base package (#1725). * Markdown link text now supports non-code markup like bold and italic, e.g., `[*italic text*][func]` generates `\link[=func]{\emph{italic text}}`, matching R's support for markup in `\link` text in R 4.5.0. * `object_format()` now escapes braces in class names, fixing broken Rd output for data objects with class `{` like `quote({})` (#1744). * roxygen2 no longer depends on purrr. diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 7732fd08..4137d996 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -61,10 +61,6 @@ find_package_lookup <- function(topic, pkg, pkg_dir) { base <- base_packages() if (length(pkg_has_topic) == 0) { - # no matches, so try base packages - for (bp in base) { - if (has_topic(topic, bp)) return(NA_character_) - } character() } else if (length(pkg_has_topic) == 1) { if (pkg_has_topic %in% base) { @@ -82,7 +78,7 @@ pkg_deps <- function(pkgdir) { deps <- desc::desc_get_deps(pkgdir) deps <- deps[deps$package != "R", ] deps <- deps[deps$type %in% c("Depends", "Imports", "Suggests"), ] - deps$package + c(deps$package, base_packages()) } base_packages <- function() { From dbdbec1adf65457bdacd770077e578ee1096fa8c Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Wed, 4 Mar 2026 11:18:36 -0600 Subject: [PATCH 17/25] Make tag optional --- R/markdown-link-resolve.R | 1 - R/utils-warn.R | 6 ++++++ tests/testthat/_snaps/markdown-link-resolve.md | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 4137d996..ecbabbd8 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -5,7 +5,6 @@ find_package <- function(topic, tag = NULL) { return(NA_character_) } pkg_dir <- roxy_meta_get("current_package_dir") - tag <- tag %||% roxy_tag("unknown", "") # only for tests pkg <- find_package_cached(topic, pkg = pkg, pkg_dir = pkg_dir) diff --git a/R/utils-warn.R b/R/utils-warn.R index 41a44b79..3d0b5d88 100644 --- a/R/utils-warn.R +++ b/R/utils-warn.R @@ -1,6 +1,12 @@ #' @export #' @rdname roxy_tag warn_roxy_tag <- function(tag, message, parent = NULL, envir = parent.frame()) { + if (is.null(tag)) { + names(message)[[1]] <- "x" + cli::cli_inform(message, parent = parent, .envir = envir) + return(invisible()) + } + tag_name <- cli::format_inline("{.strong @{tag$tag}} ") if (is.null(tag$raw)) { tag_name <- paste(tag_name, "(automatically generated) ") diff --git a/tests/testthat/_snaps/markdown-link-resolve.md b/tests/testthat/_snaps/markdown-link-resolve.md index 1dbe477d..601c29c8 100644 --- a/tests/testthat/_snaps/markdown-link-resolve.md +++ b/tests/testthat/_snaps/markdown-link-resolve.md @@ -3,7 +3,7 @@ Code find_package("doesntexist") Message - x NA:NA: @unknown Could not resolve link to topic "doesntexist" in the dependencies or base packages. + x Could not resolve link to topic "doesntexist" in the dependencies or base packages i If you haven't documented "doesntexist" yet, or just changed its name, this is normal. Once "doesntexist" is documented, this warning goes away. i Make sure that the name of the topic is spelled correctly. i Always list the linked package as a dependency. @@ -16,7 +16,7 @@ Code find_package("pkg_env") Message - x NA:NA: @unknown Topic "pkg_env" is available in multiple packages: pkgload and rlang. + x Topic "pkg_env" is available in multiple packages: pkgload and rlang i Qualify topic explicitly with a package name when linking to it. Output [1] NA From 6fde988d472ad53ab94727656d65f2c31ac4cffd Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Wed, 4 Mar 2026 16:50:13 -0600 Subject: [PATCH 18/25] Correct infix link resolution --- NEWS.md | 1 + R/markdown-link.R | 15 ++++++++------- tests/testthat/test-markdown-link.R | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/NEWS.md b/NEWS.md index 14ef7616..2859df96 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # roxygen2 (development version) * Markdown link resolution is now cached, fixing a performance regression when documenting packages with many cross-package links (#1724). +* Markdown links to operators containing `%` (e.g. `[%in%]`) are now resolved correctly (#1728). * Markdown link resolution includes base packages in the ambiguity resolution detection, so they will no longer silently match a non-base package (#1725). * Markdown link text now supports non-code markup like bold and italic, e.g., `[*italic text*][func]` generates `\link[=func]{\emph{italic text}}`, matching R's support for markup in `\link` text in R 4.5.0. * `object_format()` now escapes braces in class names, fixing broken Rd output for data objects with class `{` like `quote({})` (#1744). diff --git a/R/markdown-link.R b/R/markdown-link.R index 62a28e7f..8483a9bd 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -131,21 +131,22 @@ parse_link <- function(destination, contents, state) { thispkg <- roxy_meta_get("current_package") %||% "" is_code <- is_code || (grepl("[(][)]$", destination) && !has_link_text) pkg <- str_match(destination, "^(.*)::")[1, 2] - pkg <- gsub("%", "\\\\%", pkg) fun <- utils::tail(strsplit(destination, "::", fixed = TRUE)[[1]], 1) - fun <- gsub("%", "\\\\%", fun) - is_fun <- grepl("[(][)]$", fun) - obj <- sub("[(][)]$", "", fun) - s4 <- str_detect(destination, "-class$") - noclass <- str_match(fun, "^(.*)-class$")[1, 2] - if (is.na(pkg)) { pkg <- find_package(obj, tag = state$tag) } else if (!is.na(pkg) && pkg == thispkg) { pkg <- NA_character_ } + + pkg <- gsub("%", "\\\\%", pkg) + fun <- gsub("%", "\\\\%", fun) + is_fun <- grepl("[(][)]$", fun) + obj <- sub("[(][)]$", "", fun) file <- find_topic_filename(pkg, obj, state$tag) + s4 <- str_detect(destination, "-class$") + noclass <- str_match(fun, "^(.*)-class$")[1, 2] + ## To understand this, look at the RD column of the table above if (!has_link_text) { paste0( diff --git a/tests/testthat/test-markdown-link.R b/tests/testthat/test-markdown-link.R index aa3461ca..2def4f4d 100644 --- a/tests/testthat/test-markdown-link.R +++ b/tests/testthat/test-markdown-link.R @@ -52,6 +52,8 @@ test_that("% in links are escaped", { expect_equal(markdown("[%][x]"), r"(\link[=x]{\%})") expect_equal(markdown("[%%]"), r"(\link{\%\%})") expect_equal(markdown("[base::%%]"), r"(\link[base:Arithmetic]{base::\%\%})") + # %in% can be resolved to base package (#1728) + expect_equal(markdown("[%in%]"), r"(\link{\%in\%})") }) test_that("{ and } in links are escaped (#1259)", { @@ -602,4 +604,3 @@ test_that("percents are escaped in link targets", { )[[1]] expect_equivalent_rd(out1, out2) }) - From 15c6009bdd05cb4a193b53e554461a2cfb158265 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Wed, 4 Mar 2026 16:52:16 -0600 Subject: [PATCH 19/25] Oops --- R/markdown-link.R | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/R/markdown-link.R b/R/markdown-link.R index 8483a9bd..eca7a0ea 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -132,20 +132,23 @@ parse_link <- function(destination, contents, state) { is_code <- is_code || (grepl("[(][)]$", destination) && !has_link_text) pkg <- str_match(destination, "^(.*)::")[1, 2] fun <- utils::tail(strsplit(destination, "::", fixed = TRUE)[[1]], 1) + is_fun <- grepl("[(][)]$", fun) + obj <- sub("[(][)]$", "", fun) + s4 <- str_detect(destination, "-class$") + noclass <- str_match(fun, "^(.*)-class$")[1, 2] + + # Lookup topic using unescaped names, then escape % for Rd output if (is.na(pkg)) { pkg <- find_package(obj, tag = state$tag) } else if (!is.na(pkg) && pkg == thispkg) { pkg <- NA_character_ } + file <- find_topic_filename(pkg, obj, state$tag) pkg <- gsub("%", "\\\\%", pkg) fun <- gsub("%", "\\\\%", fun) - is_fun <- grepl("[(][)]$", fun) - obj <- sub("[(][)]$", "", fun) - file <- find_topic_filename(pkg, obj, state$tag) - - s4 <- str_detect(destination, "-class$") - noclass <- str_match(fun, "^(.*)-class$")[1, 2] + obj <- gsub("%", "\\\\%", obj) + noclass <- gsub("%", "\\\\%", noclass) ## To understand this, look at the RD column of the table above if (!has_link_text) { From 7aff6eba2430e7128dc588bb2a65ba521cc4c5c1 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Thu, 5 Mar 2026 08:01:28 -0600 Subject: [PATCH 20/25] Don't change the link text --- NEWS.md | 1 + R/markdown-link.R | 3 ++- tests/testthat/test-markdown-link.R | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 623f78ad..dd10a2cb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,7 @@ * Markdown link resolution is now cached, fixing a performance regression when documenting packages with many cross-package links (#1724). * Markdown links to operators containing `%` (e.g. `[%in%]`) are now resolved correctly (#1728). +* Markdown links like `[fun()]` that are automatically resolved to an external package no longer prefix the link text with the package name. For example, `[fun()]` now generates `\link[pkg:fun]{fun()}` instead of `\link[pkg:fun]{pkg::fun()}` (#1662). * Markdown link resolution includes base packages in the ambiguity resolution detection, so they will no longer silently match a non-base package (#1725). * `@description` no longer errors when the markdown text starts with a heading (#1705). * Markdown horizontal rules (e.g. `----`) now generate a clear warning instead of an internal error about an unknown `thematic_break` xml node (#1707). diff --git a/R/markdown-link.R b/R/markdown-link.R index eca7a0ea..0b412aa4 100644 --- a/R/markdown-link.R +++ b/R/markdown-link.R @@ -131,6 +131,7 @@ parse_link <- function(destination, contents, state) { thispkg <- roxy_meta_get("current_package") %||% "" is_code <- is_code || (grepl("[(][)]$", destination) && !has_link_text) pkg <- str_match(destination, "^(.*)::")[1, 2] + explicit_pkg <- !is.na(pkg) fun <- utils::tail(strsplit(destination, "::", fixed = TRUE)[[1]], 1) is_fun <- grepl("[(][)]$", fun) obj <- sub("[(][)]$", "", fun) @@ -160,7 +161,7 @@ parse_link <- function(destination, contents, state) { if (!is.na(pkg)) paste0(pkg, ":"), if (is_fun || !is.na(pkg)) paste0(if (is.na(pkg)) obj else file, "]"), "{", - if (!is.na(pkg)) paste0(pkg, "::"), + if (explicit_pkg && !is.na(pkg)) paste0(pkg, "::"), if (s4) noclass else fun, "}", if (is_code) "}" else "" diff --git a/tests/testthat/test-markdown-link.R b/tests/testthat/test-markdown-link.R index 2def4f4d..450a151e 100644 --- a/tests/testthat/test-markdown-link.R +++ b/tests/testthat/test-markdown-link.R @@ -583,6 +583,16 @@ test_that("linking to self is unqualified", { ) }) +test_that("resolved links don't change link text (#1662)", { + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + expect_equal( + markdown("[cli_abort()]"), + r"(\code{\link[cli:cli_abort]{cli_abort()}})" + ) +}) + test_that("percents are escaped in link targets", { out1 <- roc_proc_text( rd_roclet(), From 5cf7ad05432e4c51b052c6c4c6b580cc80423434 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Thu, 5 Mar 2026 09:20:23 -0600 Subject: [PATCH 21/25] Combine bullets --- NEWS.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index dd10a2cb..bcc58352 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,9 +1,6 @@ # roxygen2 (development version) -* Markdown link resolution is now cached, fixing a performance regression when documenting packages with many cross-package links (#1724). -* Markdown links to operators containing `%` (e.g. `[%in%]`) are now resolved correctly (#1728). -* Markdown links like `[fun()]` that are automatically resolved to an external package no longer prefix the link text with the package name. For example, `[fun()]` now generates `\link[pkg:fun]{fun()}` instead of `\link[pkg:fun]{pkg::fun()}` (#1662). -* Markdown link resolution includes base packages in the ambiguity resolution detection, so they will no longer silently match a non-base package (#1725). +* Markdown links now do a better job of resolving package names: the process is cached for better performance (#1724); it works with infix operators (e.g. `[%in%]`) (#1728); no longer changes the link text (#1662); and includes base packages when reporting ambiguous functions (#1725). * `@description` no longer errors when the markdown text starts with a heading (#1705). * Markdown horizontal rules (e.g. `----`) now generate a clear warning instead of an internal error about an unknown `thematic_break` xml node (#1707). * Markdown link text now supports non-code markup like bold and italic, e.g., `[*italic text*][func]` generates `\link[=func]{\emph{italic text}}`, matching R's support for markup in `\link` text in R 4.5.0. From 541cbc700cf6dc733c68b7ebc8ff95a347dbb951 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Thu, 5 Mar 2026 15:38:43 -0600 Subject: [PATCH 22/25] Don't cache current package lookup --- R/markdown-link-resolve.R | 15 ++++++++------- tests/testthat/test-markdown-link-resolve.R | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index ecbabbd8..152114fa 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -39,20 +39,21 @@ find_package <- function(topic, tag = NULL) { find_package_cache <- new_environment() find_package_cached <- function(topic, pkg, pkg_dir) { + # Don't cache lookups in the current package since topics may be + # added during the current session + if (has_topic(topic, pkg)) { + return(NA_character_) + } + key <- paste0(pkg, "::", topic) - env_cache(find_package_cache, key, find_package_lookup(topic, pkg, pkg_dir)) + env_cache(find_package_cache, key, find_package_dep_lookup(topic, pkg_dir)) } # NA_character() = found, doesn't need qualification # character(0) = not found # character(1) = one match # character(>1) = multiple matches -find_package_lookup <- function(topic, pkg, pkg_dir) { - # if it is in the current package, then no need for package name - if (has_topic(topic, pkg)) { - return(NA_character_) - } - +find_package_dep_lookup <- function(topic, pkg_dir) { pkgs <- pkg_deps(pkg_dir) pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, \(pkg) find_source(topic, pkg)) diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 7159202d..6b8092cc 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -48,7 +48,7 @@ test_that("gives useful warning if same name in multiple packages", { local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) expect_equal( - find_package_lookup("pkg_env", "testMdLinks", test_path("testMdLinks")), + find_package_dep_lookup("pkg_env", test_path("testMdLinks")), c("pkgload", "rlang") ) From 7235e6adf8b9ae84ec4fdcead5c4bb625fa87177 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Thu, 5 Mar 2026 16:02:16 -0600 Subject: [PATCH 23/25] Handle base ambiguities --- R/markdown-link-resolve.R | 3 +++ tests/testthat/test-markdown-link-resolve.R | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 152114fa..785b6770 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -69,6 +69,9 @@ find_package_dep_lookup <- function(topic, pkg_dir) { } else { pkg_has_topic } + } else if (all(pkg_has_topic %in% base)) { + # multiple base packages, no qualification needed + NA_character_ } else { pkg_has_topic } diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index 6b8092cc..b2b2c6c6 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -42,6 +42,14 @@ test_that("re-exported topics are identified", { expect_equal(find_package("process"), "processx") }) +test_that("topics in multiple base packages don't need qualification", { + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + # plot is in both base and graphics + expect_equal(find_package("plot"), NA_character_) +}) + test_that("gives useful warning if same name in multiple packages", { skip_on_cran() # in case pkgload/rlang changes this local_roxy_meta_set("current_package", "testMdLinks") From 4479bfd0b6e02d2bbdd15852a7890fcbbebd56a2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Thu, 5 Mar 2026 16:30:51 -0600 Subject: [PATCH 24/25] Cache `find_topic()` because it's bit slow And then recache on each run --- R/markdown-link-resolve.R | 23 ++++++++++++++------- R/roxygenize.R | 1 + tests/testthat/test-markdown-link-resolve.R | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index 785b6770..f0467292 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -38,22 +38,29 @@ find_package <- function(topic, tag = NULL) { } find_package_cache <- new_environment() -find_package_cached <- function(topic, pkg, pkg_dir) { - # Don't cache lookups in the current package since topics may be - # added during the current session - if (has_topic(topic, pkg)) { - return(NA_character_) - } +find_package_cache_reset <- function() { + # run in roxygenize() + env_unbind(find_package_cache, env_names(find_package_cache)) + # also reset the cache used by dev_help() (used by has_topic()) + pkg <- roxy_meta_get("current_package") + if (!is.null(pkg)) pkgload::dev_topic_index_reset(pkg) +} +find_package_cached <- function(topic, pkg, pkg_dir) { key <- paste0(pkg, "::", topic) - env_cache(find_package_cache, key, find_package_dep_lookup(topic, pkg_dir)) + env_cache(find_package_cache, key, find_package_lookup(topic, pkg, pkg_dir)) } # NA_character() = found, doesn't need qualification # character(0) = not found # character(1) = one match # character(>1) = multiple matches -find_package_dep_lookup <- function(topic, pkg_dir) { +find_package_lookup <- function(topic, pkg, pkg_dir) { + # if it is in the current package, then no need for package name + if (has_topic(topic, pkg)) { + return(NA_character_) + } + pkgs <- pkg_deps(pkg_dir) pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)] pkg_has_topic <- map_chr(pkg_has_topic, \(pkg) find_source(topic, pkg)) diff --git a/R/roxygenize.R b/R/roxygenize.R index 9cf349da..7c139822 100644 --- a/R/roxygenize.R +++ b/R/roxygenize.R @@ -33,6 +33,7 @@ roxygenize <- function( base_path <- normalizePath(package.dir) is_first <- roxygen_setup(base_path) + find_package_cache_reset() roxy_meta_load(base_path) # Load required packages for method registration packages <- roxy_meta_get("packages") diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index b2b2c6c6..ef75ea1c 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -56,7 +56,7 @@ test_that("gives useful warning if same name in multiple packages", { local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) expect_equal( - find_package_dep_lookup("pkg_env", test_path("testMdLinks")), + find_package_lookup("pkg_env", "testMdLinks", test_path("testMdLinks")), c("pkgload", "rlang") ) From d0d2284b3c592a1e273c33ba35c3f7df5fea311e Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Fri, 6 Mar 2026 07:34:02 -0600 Subject: [PATCH 25/25] Final polishing --- R/markdown-link-resolve.R | 22 ++++++++-------- .../testthat/_snaps/markdown-link-resolve.md | 12 +++------ tests/testthat/test-markdown-link-resolve.R | 26 ++++++++++--------- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/R/markdown-link-resolve.R b/R/markdown-link-resolve.R index f0467292..09191642 100644 --- a/R/markdown-link-resolve.R +++ b/R/markdown-link-resolve.R @@ -1,18 +1,17 @@ find_package <- function(topic, tag = NULL) { - pkg <- roxy_meta_get("current_package") - if (is.null(pkg)) { + cur_pkg <- roxy_meta_get("current_package") + cur_pkg_dir <- roxy_meta_get("current_package_dir") + if (is.null(cur_pkg)) { # Don't try and link in basic tests return(NA_character_) } - pkg_dir <- roxy_meta_get("current_package_dir") - - pkg <- find_package_cached(topic, pkg = pkg, pkg_dir = pkg_dir) + pkg <- find_package_cached(topic, pkg = cur_pkg, pkg_dir = cur_pkg_dir) if (length(pkg) == 0) { warn_roxy_tag( tag, c( - "Could not resolve link to topic {.val {topic}} in the dependencies or base packages", + "Could not resolve link to topic {.val {topic}} in the dependencies or base packages.", "i" = paste( "If you haven't documented {.val {topic}} yet, or just changed its name, this is normal.", "Once {.val {topic}} is documented, this warning goes away." @@ -29,7 +28,7 @@ find_package <- function(topic, tag = NULL) { warn_roxy_tag( tag, c( - "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg}}", + "Topic {.val {topic}} is available in multiple packages: {.pkg {pkg}}.", i = "Qualify topic explicitly with a package name when linking to it." ) ) @@ -38,8 +37,8 @@ find_package <- function(topic, tag = NULL) { } find_package_cache <- new_environment() +# run in roxygenize() because the documented functions might change between runs find_package_cache_reset <- function() { - # run in roxygenize() env_unbind(find_package_cache, env_names(find_package_cache)) # also reset the cache used by dev_help() (used by has_topic()) @@ -51,9 +50,9 @@ find_package_cached <- function(topic, pkg, pkg_dir) { env_cache(find_package_cache, key, find_package_lookup(topic, pkg, pkg_dir)) } -# NA_character() = found, doesn't need qualification -# character(0) = not found -# character(1) = one match +# NA_character = found, doesn't need qualification +# character(0) = not found +# character(1) = one match # character(>1) = multiple matches find_package_lookup <- function(topic, pkg, pkg_dir) { # if it is in the current package, then no need for package name @@ -68,6 +67,7 @@ find_package_lookup <- function(topic, pkg, pkg_dir) { base <- base_packages() if (length(pkg_has_topic) == 0) { + # can't find it anywhere character() } else if (length(pkg_has_topic) == 1) { if (pkg_has_topic %in% base) { diff --git a/tests/testthat/_snaps/markdown-link-resolve.md b/tests/testthat/_snaps/markdown-link-resolve.md index 601c29c8..af1a5318 100644 --- a/tests/testthat/_snaps/markdown-link-resolve.md +++ b/tests/testthat/_snaps/markdown-link-resolve.md @@ -1,23 +1,19 @@ # useful warning if no topic found Code - find_package("doesntexist") + . <- find_package("doesntexist") Message - x Could not resolve link to topic "doesntexist" in the dependencies or base packages + x Could not resolve link to topic "doesntexist" in the dependencies or base packages. i If you haven't documented "doesntexist" yet, or just changed its name, this is normal. Once "doesntexist" is documented, this warning goes away. i Make sure that the name of the topic is spelled correctly. i Always list the linked package as a dependency. i Alternatively, you can fully qualify the link with a package name. - Output - [1] NA # gives useful warning if same name in multiple packages Code - find_package("pkg_env") + . <- find_package("pkg_env") Message - x Topic "pkg_env" is available in multiple packages: pkgload and rlang + x Topic "pkg_env" is available in multiple packages: pkgload and rlang. i Qualify topic explicitly with a package name when linking to it. - Output - [1] NA diff --git a/tests/testthat/test-markdown-link-resolve.R b/tests/testthat/test-markdown-link-resolve.R index ef75ea1c..5bb212d3 100644 --- a/tests/testthat/test-markdown-link-resolve.R +++ b/tests/testthat/test-markdown-link-resolve.R @@ -32,22 +32,14 @@ test_that("useful warning if no topic found", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_snapshot(find_package("doesntexist")) + expect_snapshot(. <- find_package("doesntexist")) }) test_that("re-exported topics are identified", { local_roxy_meta_set("current_package", "testMdLinks") local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - expect_equal(find_package("process"), "processx") -}) - -test_that("topics in multiple base packages don't need qualification", { - local_roxy_meta_set("current_package", "testMdLinks") - local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) - - # plot is in both base and graphics - expect_equal(find_package("plot"), NA_character_) + expect_equal(. <- find_package("process"), "processx") }) test_that("gives useful warning if same name in multiple packages", { @@ -60,12 +52,22 @@ test_that("gives useful warning if same name in multiple packages", { c("pkgload", "rlang") ) - expect_snapshot(find_package("pkg_env")) + expect_snapshot(. <- find_package("pkg_env")) +}) + + +test_that("topic found in multiple base packages doesn't warn", { + local_roxy_meta_set("current_package", "testMdLinks") + local_roxy_meta_set("current_package_dir", test_path("testMdLinks")) + + # plot is in both base and graphics + expect_no_message(expect_equal(find_package("plot"), NA_character_)) }) -test_that("find_source returns base package as-is", { +test_that("find_source handles simple cases", { skip_on_cran() # since depends on other packages + # in base package expect_equal(find_source("list", "base"), "base") # topic not in namespace expect_equal(find_source("doesnt'exist", "cli"), "cli")