Skip to content

Commit b94b42f

Browse files
authored
Resolve markdown links to external packages (#1633)
Closes #1612.
1 parent 10ec8de commit b94b42f

File tree

13 files changed

+488
-143
lines changed

13 files changed

+488
-143
lines changed

NEWS.md

Lines changed: 137 additions & 134 deletions
Large diffs are not rendered by default.

R/markdown-link.R

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,27 @@
1111
#' ```
1212
#' MARKDOWN LINK TEXT CODE RD
1313
#' -------- --------- ---- --
14-
#' [fun()] fun() T \\link[=fun]{fun()}
15-
#' [obj] obj F \\link{obj}
14+
#' [fun()] fun() T \\link[=fun]{fun()} or
15+
#' \\link[pkg:file]{pkg::fun()}
16+
#' [obj] obj F \\link{obj} or
17+
#' \\link[pkg:file]{pkg::obj}
1618
#' [pkg::fun()] pkg::fun() T \\link[pkg:file]{pkg::fun()}
1719
#' [pkg::obj] pkg::obj F \\link[pkg:file]{pkg::obj}
18-
#' [text][fun()] text F \\link[=fun]{text}
19-
#' [text][obj] text F \\link[=obj]{text}
20+
#' [text][fun()] text F \\link[=fun]{text} or
21+
#' \\link[pkg:file]{text}
22+
#' [text][obj] text F \\link[=obj]{text} or
23+
#' \\link[pkg:file]{text}
2024
#' [text][pkg::fun()] text F \\link[pkg:file]{text}
2125
#' [text][pkg::obj] text F \\link[pkg:file]{text}
22-
#' [s4-class] s4 F \\linkS4class{s4}
26+
#' [s4-class] s4 F \\linkS4class{s4} or
27+
#' \\link[pkg:file]{s4}
2328
#' [pkg::s4-class] pkg::s4 F \\link[pkg:file]{pkg::s4}
2429
#' ```
2530
#'
31+
#' For the links with two RD variants the first version is used for
32+
#' within-package links, and the second version is used for cross-package
33+
#' links.
34+
#'
2635
#' The reference links will always look like `R:ref` for `[ref]` and
2736
#' `[text][ref]`. These are explicitly tested in `test-rd-markdown-links.R`.
2837
#'
@@ -131,13 +140,15 @@ parse_link <- function(destination, contents, state) {
131140
is_code <- is_code || (grepl("[(][)]$", destination) && ! has_link_text)
132141
pkg <- str_match(destination, "^(.*)::")[1,2]
133142
pkg <- gsub("%", "\\\\%", pkg)
134-
if (!is.na(pkg) && pkg == thispkg) pkg <- NA_character_
135143
fun <- utils::tail(strsplit(destination, "::", fixed = TRUE)[[1]], 1)
136144
fun <- gsub("%", "\\\\%", fun)
137145
is_fun <- grepl("[(][)]$", fun)
138146
obj <- sub("[(][)]$", "", fun)
139147
s4 <- str_detect(destination, "-class$")
140148
noclass <- str_match(fun, "^(.*)-class$")[1,2]
149+
150+
if (is.na(pkg)) pkg <- resolve_link_package(obj, thispkg, state = state)
151+
if (!is.na(pkg) && pkg == thispkg) pkg <- NA_character_
141152
file <- find_topic_filename(pkg, obj, state$tag)
142153

143154
## To understand this, look at the RD column of the table above
@@ -174,6 +185,103 @@ parse_link <- function(destination, contents, state) {
174185
}
175186
}
176187

188+
resolve_link_package <- function(topic, me = NULL, pkgdir = NULL, state = NULL) {
189+
me <- me %||% roxy_meta_get("current_package")
190+
# this is from the roxygen2 tests, should not happen on a real package
191+
if (is.null(me) || is.na(me) || me == "") return(NA_character_)
192+
193+
# if it is in the current package, then no need for package name, right?
194+
if (has_topic(topic, me)) return(NA_character_)
195+
196+
# try packages in depends, imports, suggests first, error on name clashes
197+
pkgs <- local_pkg_deps(pkgdir)
198+
199+
pkg_has_topic <- pkgs[map_lgl(pkgs, has_topic, topic = topic)]
200+
pkg_has_topic <- map_chr(pkg_has_topic, function(p) {
201+
p0 <- find_reexport_source(topic, p)
202+
if (is.na(p0)) p else p0
203+
})
204+
pkg_has_topic <- unique(pkg_has_topic)
205+
base <- base_packages()
206+
if (length(pkg_has_topic) == 0) {
207+
# fall through to check base packages as well
208+
} else if (length(pkg_has_topic) == 1) {
209+
if (pkg_has_topic %in% base) {
210+
return(NA_character_)
211+
} else {
212+
return(pkg_has_topic)
213+
}
214+
} else {
215+
warn_roxy_tag(state$tag, c(
216+
"Topic {.val {topic}} is available in multiple packages: {.pkg {pkg_has_topic}}",
217+
i = "Qualify topic explicitly with a package name when linking to it."
218+
))
219+
return(NA_character_)
220+
}
221+
222+
# try base packages as well, take the first hit,
223+
# there should not be any name clashes, anyway
224+
for (bp in base) {
225+
if (has_topic(topic, bp)) return(NA_character_)
226+
}
227+
228+
warn_roxy_tag(state$tag, c(
229+
"Could not resolve link to topic {.val {topic}} in the dependencies or base packages",
230+
"i" = paste(
231+
"If you haven't documented {.val {topic}} yet, or just changed its name, this is normal.",
232+
"Once {.val {topic}} is documented, this warning goes away."),
233+
"i" = "Make sure that the name of the topic is spelled correctly.",
234+
"i" = "Always list the linked package as a dependency.",
235+
"i" = "Alternatively, you can fully qualify the link with a package name."
236+
))
237+
238+
NA_character_
239+
}
240+
241+
# this is mostly from downlit
242+
is_exported <- function(name, package) {
243+
name %in% getNamespaceExports(ns_env(package))
244+
}
245+
246+
is_reexported <- function(name, package) {
247+
if (package == "base") {
248+
return(FALSE)
249+
}
250+
is_imported <- env_has(ns_imports_env(package), name)
251+
is_imported && is_exported(name, package)
252+
}
253+
254+
find_reexport_source <- function(topic, package) {
255+
ns <- ns_env(package)
256+
if (!env_has(ns, topic, inherit = TRUE)) {
257+
return(NA_character_)
258+
}
259+
260+
obj <- env_get(ns, topic, inherit = TRUE)
261+
if (is.primitive(obj)) {
262+
# primitive functions all live in base
263+
"base"
264+
} else if (is.function(obj)) {
265+
## For functions, we can just take their environment.
266+
ns_env_name(get_env(obj))
267+
} else {
268+
## For other objects, we need to check the import env of the package,
269+
## to see where 'topic' is coming from. The import env has redundant
270+
## information. It seems that we just need to find a named list
271+
## entry that contains `topic`.
272+
imp <- getNamespaceImports(ns)
273+
imp <- imp[names(imp) != ""]
274+
wpkgs <- vapply(imp, `%in%`, x = topic, FUN.VALUE = logical(1))
275+
276+
if (!any(wpkgs)) {
277+
return(NA_character_)
278+
}
279+
pkgs <- names(wpkgs)[wpkgs]
280+
# Take the last match, in case imports have name clashes.
281+
pkgs[[length(pkgs)]]
282+
}
283+
}
284+
177285
#' Dummy page to test roxygen's markdown formatting
178286
#'
179287
#' Links are very tricky, so I'll put in some links here:

R/markdown.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ markdown <- function(text, tag = NULL, sections = FALSE) {
1717
)
1818
}
1919

20+
mddata <- new.env(parent = emptyenv())
21+
2022
#' Expand the embedded inline code
2123
#'
2224
#' @details
@@ -68,6 +70,7 @@ markdown <- function(text, tag = NULL, sections = FALSE) {
6870
#' @keywords internal
6971

7072
markdown_pass1 <- function(text) {
73+
rm(list = ls(envir = mddata), envir = mddata)
7174
text <- paste(text, collapse = "\n")
7275
mdxml <- xml_ns_strip(md_to_mdxml(text, sourcepos = TRUE))
7376
code_nodes <- xml_find_all(mdxml, ".//code | .//code_block")

R/options.R

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ load_options <- function(base_path = ".") {
6666
markdown = FALSE,
6767
r6 = TRUE,
6868
current_package = NA_character_,
69+
current_package_dir = NA_character_,
6970
rd_family_title = list(),
7071
knitr_chunk_options = NULL,
7172
restrict_image_formats = TRUE
@@ -94,6 +95,7 @@ load_options_description <- function(base_path = ".") {
9495
}
9596

9697
opts$current_package <- dcf[[1, 2]]
98+
opts$current_package_dir <- normalizePath(base_path)
9799
opts
98100
}
99101

R/utils.R

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,29 @@ auto_quote <- function(x) {
182182
is_syntactic <- function(x) make.names(x) == x
183183
has_quotes <- function(x) str_detect(x, "^(`|'|\").*\\1$")
184184
strip_quotes <- function(x) str_replace(x, "^(`|'|\")(.*)\\1$", "\\2")
185+
186+
base_packages <- function() {
187+
if (getRversion() >= "4.4.0") {
188+
asNamespace("tools")$standard_package_names()[["base"]]
189+
} else {
190+
c("base", "compiler", "datasets",
191+
"graphics", "grDevices", "grid", "methods", "parallel",
192+
"splines", "stats", "stats4", "tcltk", "tools", "utils"
193+
)
194+
}
195+
}
196+
197+
# Note that this caches the result regardless of
198+
# pkgdir! pkgdir is mainly for testing, in which case you
199+
# need to clear the cache manually.
200+
201+
local_pkg_deps <- function(pkgdir = NULL) {
202+
if (!is.null(mddata[["deps"]])) {
203+
return(mddata[["deps"]])
204+
}
205+
pkgdir <- pkgdir %||% roxy_meta_get("current_package_dir")
206+
deps <- desc::desc_get_deps(pkgdir)
207+
deps <- deps[deps$package != "R", ]
208+
deps <- deps[deps$type %in% c("Depends", "Imports", "Suggests"), ]
209+
deps$package
210+
}

tests/testthat/_snaps/markdown-link.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,43 @@
3636
Message
3737
x <text>:4: @description refers to unavailable topic stringr::bar111.
3838

39+
# resolve_link_package
40+
41+
Code
42+
resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2"))
43+
Output
44+
[1] NA
45+
Code
46+
resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2"))
47+
Output
48+
[1] NA
49+
Code
50+
resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2"))
51+
Output
52+
[1] "cli"
53+
54+
---
55+
56+
Code
57+
resolve_link_package("aa3bc042880aa3b64fef1a9", "roxygen2", test_path(
58+
"testMdLinks2"), list(tag = tag))
59+
Message
60+
x foo.R:10: @title (automatically generated) Could not resolve link to topic "aa3bc042880aa3b64fef1a9" in the dependencies or base packages.
61+
i If you haven't documented "aa3bc042880aa3b64fef1a9" yet, or just changed its name, this is normal. Once "aa3bc042880aa3b64fef1a9" is documented, this warning goes away.
62+
i Make sure that the name of the topic is spelled correctly.
63+
i Always list the linked package as a dependency.
64+
i Alternatively, you can fully qualify the link with a package name.
65+
Output
66+
[1] NA
67+
68+
# resolve_link_package name clash
69+
70+
Code
71+
resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks2"), list(
72+
tag = tag))
73+
Message
74+
x foo.R:10: @title (automatically generated) Topic "pkg_env" is available in multiple packages: pkgload and rlang.
75+
i Qualify topic explicitly with a package name when linking to it.
76+
Output
77+
[1] NA
78+

tests/testthat/test-markdown-link.R

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,3 +477,41 @@ test_that("percents are escaped in link targets", {
477477
foo <- function() {}")[[1]]
478478
expect_equivalent_rd(out1, out2)
479479
})
480+
481+
test_that("resolve_link_package", {
482+
rm(list = ls(envir = mddata), envir = mddata)
483+
expect_snapshot({
484+
resolve_link_package("roxygenize", "roxygen2", test_path("testMdLinks2"))
485+
resolve_link_package("UseMethod", "roxygen2", test_path("testMdLinks2"))
486+
resolve_link_package("cli_abort", "roxygen2", test_path("testMdLinks2"))
487+
})
488+
489+
tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10)
490+
expect_snapshot({
491+
resolve_link_package("aa3bc042880aa3b64fef1a9", "roxygen2", test_path("testMdLinks2"), list(tag = tag))
492+
})
493+
# re-exported topics are identified
494+
rm(list = ls(envir = mddata), envir = mddata)
495+
expect_equal(
496+
resolve_link_package("process", "testthat", test_path("testMdLinks")),
497+
"processx"
498+
)
499+
})
500+
501+
test_that("resolve_link_package name clash", {
502+
# skip in case pkgload/rlang changes this
503+
skip_on_cran()
504+
tag <- roxy_tag("title", NULL, NULL, file = "foo.R", line = 10)
505+
expect_snapshot({
506+
resolve_link_package("pkg_env", "roxygen2", test_path("testMdLinks2"), list(tag = tag))
507+
})
508+
})
509+
510+
test_that("is_reexported", {
511+
expect_false(is_reexported("process", "processx"))
512+
expect_true(is_reexported("process", "callr"))
513+
})
514+
515+
test_that("find_reexport_source", {
516+
expect_equal(find_reexport_source("process", "callr"), "processx")
517+
})

tests/testthat/test-rd-describe-in.R

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,3 @@ test_that("complains about bad usage", {
263263
"
264264
expect_snapshot(. <- roc_proc_text(rd_roclet(), block))
265265
})
266-

tests/testthat/test-rd-include-rmd.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
skip_if_not_installed("rmarkdown")
22

3+
# clear some state
4+
roxy_meta_clear()
5+
36
test_that("invalid syntax gives useful warning", {
47
block <- "
58
#' @includeRmd
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
Package: testthat
2+
Title: Unit Testing for R
3+
Version: 3.2.1.9000
4+
Authors@R: c(
5+
person("Hadley", "Wickham", , "[email protected]", role = c("aut", "cre")),
6+
person("Posit Software, PBC", role = c("cph", "fnd")),
7+
person("R Core team", role = "ctb",
8+
comment = "Implementation of utils::recover()")
9+
)
10+
Description: Software testing is important, but, in part because it is
11+
frustrating and boring, many of us avoid it. 'testthat' is a testing
12+
framework for R that is easy to learn and use, and integrates with
13+
your existing 'workflow'.
14+
License: MIT + file LICENSE
15+
URL: https://testthat.r-lib.org, https://github.com/r-lib/testthat
16+
BugReports: https://github.com/r-lib/testthat/issues
17+
Depends:
18+
R (>= 3.6.0)
19+
Imports:
20+
brio (>= 1.1.3),
21+
callr (>= 3.7.3),
22+
cli (>= 3.6.1),
23+
desc (>= 1.4.2),
24+
digest (>= 0.6.33),
25+
evaluate (>= 0.21),
26+
jsonlite (>= 1.8.7),
27+
lifecycle (>= 1.0.3),
28+
magrittr (>= 2.0.3),
29+
methods,
30+
pkgload (>= 1.3.2.1),
31+
praise (>= 1.0.0),
32+
processx (>= 3.8.2),
33+
ps (>= 1.7.5),
34+
R6 (>= 2.5.1),
35+
rlang (>= 1.1.1),
36+
utils,
37+
waldo (>= 0.5.1),
38+
withr (>= 2.5.0)
39+
Suggests:
40+
covr,
41+
curl (>= 0.9.5),
42+
diffviewer (>= 0.1.0),
43+
knitr,
44+
rmarkdown,
45+
rstudioapi,
46+
shiny,
47+
usethis,
48+
vctrs (>= 0.1.0),
49+
xml2
50+
VignetteBuilder:
51+
knitr
52+
Config/Needs/website: tidyverse/tidytemplate
53+
Config/testthat/edition: 3
54+
Config/testthat/parallel: true
55+
Config/testthat/start-first: watcher, parallel*
56+
Encoding: UTF-8
57+
Roxygen: list(markdown = TRUE, r6 = FALSE)
58+
RoxygenNote: 7.2.3

0 commit comments

Comments
 (0)