From 5efa7a596428ab02c5493ffe9371551caff63818 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 09:57:55 -0500 Subject: [PATCH 01/12] `use_tidy_github_actions()` --- .Rbuildignore | 2 + .github/.gitignore | 1 + .github/workflows/R-CMD-check.yaml | 57 +++++++++++++++++++++++++ .github/workflows/format-suggest.yaml | 46 ++++++++++++++++++++ .github/workflows/pkgdown.yaml | 47 +++++++++++++++++++++ .github/workflows/test-coverage.yaml | 60 +++++++++++++++++++++++++++ README.Rmd | 2 + codecov.yml | 14 +++++++ 8 files changed, 229 insertions(+) create mode 100644 .github/.gitignore create mode 100644 .github/workflows/R-CMD-check.yaml create mode 100644 .github/workflows/format-suggest.yaml create mode 100644 .github/workflows/pkgdown.yaml create mode 100644 .github/workflows/test-coverage.yaml create mode 100644 codecov.yml diff --git a/.Rbuildignore b/.Rbuildignore index 63e1a14..0185c16 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -6,3 +6,5 @@ ^AGENTS\.md$ ^\.claude$ ^README\.Rmd$ +^\.github$ +^codecov\.yml$ diff --git a/.github/.gitignore b/.github/.gitignore new file mode 100644 index 0000000..2d19fc7 --- /dev/null +++ b/.github/.gitignore @@ -0,0 +1 @@ +*.html diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..0ac7d7d --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,57 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +# +# NOTE: This workflow is overkill for most R packages and +# check-standard.yaml is likely a better choice. +# usethis::use_github_action("check-standard") will install it. +on: + push: + branches: [main, master] + pull_request: + +name: R-CMD-check.yaml + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} + - {os: ubuntu-latest, r: 'oldrel-2'} + - {os: ubuntu-latest, r: 'oldrel-3'} + - {os: ubuntu-latest, r: 'oldrel-4'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v6 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/.github/workflows/format-suggest.yaml b/.github/workflows/format-suggest.yaml new file mode 100644 index 0000000..8c4f117 --- /dev/null +++ b/.github/workflows/format-suggest.yaml @@ -0,0 +1,46 @@ +# Workflow derived from https://github.com/posit-dev/setup-air/tree/main/examples + +on: + # Using `pull_request_target` over `pull_request` for elevated `GITHUB_TOKEN` + # privileges, otherwise we can't set `pull-requests: write` when the pull + # request comes from a fork, which is our main use case (external contributors). + # + # `pull_request_target` runs in the context of the target branch (`main`, usually), + # rather than in the context of the pull request like `pull_request` does. Due + # to this, we must explicitly checkout `ref: ${{ github.event.pull_request.head.sha }}`. + # This is typically frowned upon by GitHub, as it exposes you to potentially running + # untrusted code in a context where you have elevated privileges, but they explicitly + # call out the use case of reformatting and committing back / commenting on the PR + # as a situation that should be safe (because we aren't actually running the untrusted + # code, we are just treating it as passive data). + # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + pull_request_target: + +name: format-suggest.yaml + +jobs: + format-suggest: + name: format-suggest + runs-on: ubuntu-latest + + permissions: + # Required to push suggestion comments to the PR + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Install + uses: posit-dev/setup-air@v1 + + - name: Format + run: air format . + + - name: Suggest + uses: reviewdog/action-suggester@v1 + with: + level: error + fail_level: error + tool_name: air diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml new file mode 100644 index 0000000..16c6c8b --- /dev/null +++ b/.github/workflows/pkgdown.yaml @@ -0,0 +1,47 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + release: + types: [published] + workflow_dispatch: + +name: pkgdown.yaml + +permissions: read-all + +jobs: + pkgdown: + runs-on: ubuntu-latest + # Only restrict concurrency for non-PR jobs + concurrency: + group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::pkgdown, local::. + needs: website + + - name: Build site + run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) + shell: Rscript {0} + + - name: Deploy to GitHub pages 🚀 + if: github.event_name != 'pull_request' + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..5e820d3 --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,60 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + +name: test-coverage.yaml + +permissions: read-all + +jobs: + test-coverage: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + steps: + - uses: actions/checkout@v6 + + - uses: r-lib/actions/setup-r@v2 + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::covr, any::xml2 + needs: coverage + + - name: Test coverage + run: | + cov <- covr::package_coverage( + quiet = FALSE, + clean = FALSE, + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") + ) + print(cov) + covr::to_cobertura(cov) + shell: Rscript {0} + + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 + with: + # Fail if error if not on PR, or if on PR and token is given + fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }} + files: ./cobertura.xml + plugins: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Show testthat output + if: always() + run: | + ## -------------------------------------------------------------------- + find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true + shell: bash + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: coverage-test-failures + path: ${{ runner.temp }}/package diff --git a/README.Rmd b/README.Rmd index 5b08efd..d40a3fd 100644 --- a/README.Rmd +++ b/README.Rmd @@ -16,6 +16,8 @@ knitr::opts_chunk$set( # rdtools +[![R-CMD-check](https://github.com/r-lib/rdtools/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/r-lib/rdtools/actions/workflows/R-CMD-check.yaml) +[![Codecov test coverage](https://codecov.io/gh/r-lib/rdtools/graph/badge.svg)](https://app.codecov.io/gh/r-lib/rdtools) rdtools provides fast, cached lookup of help topics and aliases across diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..04c5585 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +comment: false + +coverage: + status: + project: + default: + target: auto + threshold: 1% + informational: true + patch: + default: + target: auto + threshold: 1% + informational: true From f2a7c3e0aeae8436a891e21a5227df2bd60674a2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 10:45:12 -0500 Subject: [PATCH 02/12] Re-document --- man/rdtools-package.Rd | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/man/rdtools-package.Rd b/man/rdtools-package.Rd index 8e1683d..75c1a29 100644 --- a/man/rdtools-package.Rd +++ b/man/rdtools-package.Rd @@ -7,6 +7,14 @@ \title{rdtools: Efficient Manipulation of 'Rd' Files and Help Topics} \description{ Fast, cached lookup of help topics and aliases across installed, source, and in-development packages, plus efficient retrieval of parsed 'Rd' objects. +} +\seealso{ +Useful links: +\itemize{ + \item \url{https://github.com/r-lib/rdtools} + \item Report bugs at \url{https://github.com/r-lib/rdtools/issues} +} + } \author{ \strong{Maintainer}: Hadley Wickham \email{hadley@posit.co} From d1dd092437b010694054d92b8189c5982269ae1f Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 11:13:14 -0500 Subject: [PATCH 03/12] Provide explicit cache reset --- NAMESPACE | 1 + R/index.R | 63 +++++++++++++++++++++++---------------- R/pkg.R | 7 +++-- man/pkg_cache_reset.Rd | 20 +++++++++++++ man/pkg_topics.Rd | 7 +++-- tests/testthat/test-pkg.R | 27 ++++++++++------- 6 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 man/pkg_cache_reset.Rd diff --git a/NAMESPACE b/NAMESPACE index d9d1d4a..01fa2c7 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(pkg_cache_reset) export(pkg_search_path) export(pkg_topics) export(topic_find) diff --git a/R/index.R b/R/index.R index 4857c9e..3d832f0 100644 --- a/R/index.R +++ b/R/index.R @@ -1,18 +1,13 @@ # Per-package topic indexes, cached in `the$index` keyed by the caller's # `package` argument (a name, or a normalized path for source packages). # Each entry is an environment so the lazily-populated Rd cache (entry$rd) -# persists without reassignment. Entries are validated on every access -# against cheap filesystem stamps and rebuilt when stale, so callers never -# need to invalidate by hand (index_reset() exists for stubborn cases). +# persists without reassignment. Installed entries are validated on every +# access. Source entries remain valid until explicitly reset, so repeated +# lookups avoid scanning every file in man/. index <- function(package) { check_string(package) - - if (is_pkg_path(package)) { - key <- normalizePath(package, mustWork = TRUE) - } else { - key <- package - } + key <- index_key(package) entry <- get0(key, envir = the$index, inherits = FALSE) if (is.null(entry) || !index_valid(entry)) { @@ -22,11 +17,34 @@ index <- function(package) { entry } -index_reset <- function() { - the$index <- new.env(parent = emptyenv()) +#' Reset cached package indexes +#' +#' Clears the cached topic index and parsed Rd objects for `package`. +#' Only needed for source packages, and will generally be automatically +#' be called by roxygen2. +#' +#' @param package A package name, or a path to the source directory of a +#' package. +#' @returns `NULL`, invisibly. +#' @export +pkg_cache_reset <- function(package) { + check_string(package) + key <- index_key(package) + + if (exists(key, envir = the$index, inherits = FALSE)) { + rm(list = key, envir = the$index) + } invisible(NULL) } +index_key <- function(package) { + if (is_pkg_path(package)) { + normalizePath(package, mustWork = TRUE) + } else { + package + } +} + # A path (as opposed to a package name) is anything containing a path # separator, or a directory that holds a DESCRIPTION. is_pkg_path <- function(package) { @@ -65,21 +83,17 @@ index_resolve <- function(package) { } } -# Cheap change-detection stamp: one stat() for installed packages; a -# directory listing plus vectorized stat() for source packages (~0.5 ms -# for a large man/, vs ~7 ms to rebuild). rd_files() sorts bytewise so -# the stamp is insensitive to readdir order. +# Cheap change-detection stamp for installed packages. index_stamp <- function(res) { - if (res$backend == "installed") { - path <- file.path(res$path, "help", "aliases.rds") - list(mtime = file.mtime(path), size = file.size(path)) - } else { - files <- rd_files(file.path(res$path, "man")) - list(files = files, mtime = file.mtime(files)) - } + path <- file.path(res$path, "help", "aliases.rds") + list(mtime = file.mtime(path), size = file.size(path)) } index_valid <- function(entry) { + if (entry$backend == "source") { + return(TRUE) + } + res <- tryCatch(index_resolve(entry$input), error = function(e) NULL) identical(res, entry$resolution) && identical(index_stamp(res), entry$stamp) @@ -87,7 +101,6 @@ index_valid <- function(entry) { index_build <- function(package) { res <- index_resolve(package) - stamp <- index_stamp(res) entry <- new.env(parent = emptyenv()) entry$input <- package @@ -95,13 +108,13 @@ index_build <- function(package) { entry$path <- res$path entry$backend <- res$backend entry$resolution <- res - entry$stamp <- stamp entry$rd <- new.env(parent = emptyenv()) if (res$backend == "installed") { + entry$stamp <- index_stamp(res) topics <- readRDS(file.path(res$path, "help", "aliases.rds")) } else { - files <- stamp$files + files <- rd_files(file.path(res$path, "man")) rd_names <- sub("\\.[Rr]d$", "", names(files)) entry$files <- stats::setNames(unname(files), rd_names) topics <- source_topics(files, rd_names) diff --git a/R/pkg.R b/R/pkg.R index a9a983f..193e9ef 100644 --- a/R/pkg.R +++ b/R/pkg.R @@ -12,9 +12,10 @@ #' * **Source** packages, when `package` is a path to a package directory #' rather than a name. #' -#' Indexes are cached, and revalidated against file modification times on -#' every access, so repeated lookups are cheap and edits to `man/` are -#' picked up automatically. +#' Indexes are cached. Installed packages are revalidated against file +#' modification times on every access. Source packages remain cached until +#' [pkg_cache_reset()] is called, avoiding repeated scans of their `man/` +#' directories. #' #' For source packages, `\alias{}` extraction is line-based: any number of #' aliases may appear anywhere on a line, but an alias must open and close diff --git a/man/pkg_cache_reset.Rd b/man/pkg_cache_reset.Rd new file mode 100644 index 0000000..c9f35ae --- /dev/null +++ b/man/pkg_cache_reset.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/index.R +\name{pkg_cache_reset} +\alias{pkg_cache_reset} +\title{Reset cached package indexes} +\usage{ +pkg_cache_reset(package) +} +\arguments{ +\item{package}{A package name, or a path to the source directory of a +package.} +} +\value{ +\code{NULL}, invisibly. +} +\description{ +Clears the cached topic index and parsed Rd objects for \code{package}. +Only needed for source packages, and will generally be automatically +be called by roxygen2. +} diff --git a/man/pkg_topics.Rd b/man/pkg_topics.Rd index 247c753..47e2aa9 100644 --- a/man/pkg_topics.Rd +++ b/man/pkg_topics.Rd @@ -26,9 +26,10 @@ indexed from the \verb{\\alias\{\}} commands in their source \verb{man/} directo rather than a name. } -Indexes are cached, and revalidated against file modification times on -every access, so repeated lookups are cheap and edits to \verb{man/} are -picked up automatically. +Indexes are cached. Installed packages are revalidated against file +modification times on every access. Source packages remain cached until +\code{\link[=pkg_cache_reset]{pkg_cache_reset()}} is called, avoiding repeated scans of their \verb{man/} +directories. For source packages, \verb{\\alias\{\}} extraction is line-based: any number of aliases may appear anywhere on a line, but an alias must open and close diff --git a/tests/testthat/test-pkg.R b/tests/testthat/test-pkg.R index e1db2a7..8df1f0e 100644 --- a/tests/testthat/test-pkg.R +++ b/tests/testthat/test-pkg.R @@ -84,29 +84,34 @@ test_that("pkg_topics() warns on duplicated aliases, last file wins", { test_that("pkg_topics() handles packages with no man directory", { path <- withr::local_tempdir() - writeLines(c("Package: empty", "Version: 0.0.1"), file.path(path, "DESCRIPTION")) + writeLines( + c("Package: empty", "Version: 0.0.1"), + file.path(path, "DESCRIPTION") + ) expect_equal(pkg_topics(path), setNames(character(), character())) }) -test_that("index is invalidated when man/ changes", { +test_that("source indexes remain cached until explicitly reset", { path <- local_test_pkg("foo.Rd" = "foo") + other <- local_test_pkg("qux.Rd" = "qux") expect_named(pkg_topics(path), "foo") + expect_named(pkg_topics(other), "qux") - # adding a file changes the directory listing write_rd(path, "baz.Rd", "baz") - expect_true("baz" %in% names(pkg_topics(path))) + write_rd(other, "zap.Rd", "zap") + expect_named(pkg_topics(path), "foo") + expect_named(pkg_topics(other), "qux") - # editing a file changes its mtime (forced, in case of coarse clocks) - write_rd(path, "baz.Rd", c("baz", "qux")) - Sys.setFileTime(file.path(path, "man", "baz.Rd"), Sys.time() + 10) - expect_true("qux" %in% names(pkg_topics(path))) + pkg_cache_reset(path) + expect_named(pkg_topics(path), c("baz", "foo")) + expect_named(pkg_topics(other), "qux") }) -test_that("index_reset() drops all cached indexes", { +test_that("pkg_cache_reset() drops the requested cached index", { pkg_topics("stats") - index_reset() - expect_length(ls(the$index), 0) + pkg_cache_reset("stats") + expect_equal(intersect(ls(the$index), "stats"), character()) # still works after a full reset expect_equal(pkg_topics("stats")[["rnorm"]], "Normal") From 50339e081acb08c15be4c039669c502996eb3fa2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 11:23:38 -0500 Subject: [PATCH 04/12] Explicitly invalidate installed packages too --- R/index.R | 59 +++++++++++++++++++-------------------- R/pkg.R | 6 ++-- R/rdtools-package.R | 1 + README.md | 9 +++--- man/pkg_cache_reset.Rd | 5 ++-- man/pkg_topics.Rd | 6 ++-- tests/testthat/test-pkg.R | 17 +++++++++++ 7 files changed, 59 insertions(+), 44 deletions(-) diff --git a/R/index.R b/R/index.R index 3d832f0..0b1c996 100644 --- a/R/index.R +++ b/R/index.R @@ -1,17 +1,16 @@ # Per-package topic indexes, cached in `the$index` keyed by the caller's # `package` argument (a name, or a normalized path for source packages). # Each entry is an environment so the lazily-populated Rd cache (entry$rd) -# persists without reassignment. Installed entries are validated on every -# access. Source entries remain valid until explicitly reset, so repeated -# lookups avoid scanning every file in man/. +# persists without reassignment. Entries remain valid until explicitly reset; +# installed entries are reset automatically when their namespace is unloaded. index <- function(package) { check_string(package) key <- index_key(package) entry <- get0(key, envir = the$index, inherits = FALSE) - if (is.null(entry) || !index_valid(entry)) { - entry <- index_build(package) + if (is.null(entry)) { + entry <- index_build(package, key) assign(key, entry, envir = the$index) } entry @@ -20,8 +19,9 @@ index <- function(package) { #' Reset cached package indexes #' #' Clears the cached topic index and parsed Rd objects for `package`. -#' Only needed for source packages, and will generally be automatically -#' be called by roxygen2. +#' This will generally be called automatically by roxygen2 for source packages. +#' Installed package indexes are automatically reset when their namespace is +#' unloaded. #' #' @param package A package name, or a path to the source directory of a #' package. @@ -31,9 +31,7 @@ pkg_cache_reset <- function(package) { check_string(package) key <- index_key(package) - if (exists(key, envir = the$index, inherits = FALSE)) { - rm(list = key, envir = the$index) - } + index_drop(key) invisible(NULL) } @@ -45,6 +43,25 @@ index_key <- function(package) { } } +index_drop <- function(key) { + if (exists(key, envir = the$index, inherits = FALSE)) { + rm(list = key, envir = the$index) + } +} + +index_register_unload <- function(package, key) { + if (exists(package, envir = the$unload_hooks, inherits = FALSE)) { + return() + } + + setHook( + packageEvent(package, "onUnload"), + function(...) index_drop(key), + action = "append" + ) + assign(package, TRUE, envir = the$unload_hooks) +} + # A path (as opposed to a package name) is anything containing a path # separator, or a directory that holds a DESCRIPTION. is_pkg_path <- function(package) { @@ -83,35 +100,17 @@ index_resolve <- function(package) { } } -# Cheap change-detection stamp for installed packages. -index_stamp <- function(res) { - path <- file.path(res$path, "help", "aliases.rds") - list(mtime = file.mtime(path), size = file.size(path)) -} - -index_valid <- function(entry) { - if (entry$backend == "source") { - return(TRUE) - } - - res <- tryCatch(index_resolve(entry$input), error = function(e) NULL) - identical(res, entry$resolution) && - identical(index_stamp(res), entry$stamp) -} - -index_build <- function(package) { +index_build <- function(package, key) { res <- index_resolve(package) entry <- new.env(parent = emptyenv()) - entry$input <- package entry$name <- res$name entry$path <- res$path entry$backend <- res$backend - entry$resolution <- res entry$rd <- new.env(parent = emptyenv()) if (res$backend == "installed") { - entry$stamp <- index_stamp(res) + index_register_unload(res$name, key) topics <- readRDS(file.path(res$path, "help", "aliases.rds")) } else { files <- rd_files(file.path(res$path, "man")) diff --git a/R/pkg.R b/R/pkg.R index 193e9ef..856185a 100644 --- a/R/pkg.R +++ b/R/pkg.R @@ -12,10 +12,8 @@ #' * **Source** packages, when `package` is a path to a package directory #' rather than a name. #' -#' Indexes are cached. Installed packages are revalidated against file -#' modification times on every access. Source packages remain cached until -#' [pkg_cache_reset()] is called, avoiding repeated scans of their `man/` -#' directories. +#' Indexes remain cached until [pkg_cache_reset()] is called. Installed package +#' indexes are also reset automatically when their namespace is unloaded. #' #' For source packages, `\alias{}` extraction is line-based: any number of #' aliases may appear anywhere on a line, but an alias must open and close diff --git a/R/rdtools-package.R b/R/rdtools-package.R index d7d0b4c..c6328b1 100644 --- a/R/rdtools-package.R +++ b/R/rdtools-package.R @@ -4,3 +4,4 @@ the <- new.env(parent = emptyenv()) the$index <- new.env(parent = emptyenv()) +the$unload_hooks <- new.env(parent = emptyenv()) diff --git a/README.md b/README.md index fc7003d..2b47d6c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ class(rd) #> [1] "Rd" ``` -All lookups are backed by per-package indexes that are cached and -automatically revalidated when the underlying files change, so they’re -cheap enough to call in a tight loop (e.g. once per link while rendering -documentation). +All lookups are backed by per-package indexes that remain cached until +explicitly reset. Installed package indexes are reset when their namespace is +unloaded, while source package indexes can be reset with +`pkg_cache_reset()`. This makes lookups cheap enough to call in a tight loop +(e.g. once per link while rendering documentation). diff --git a/man/pkg_cache_reset.Rd b/man/pkg_cache_reset.Rd index c9f35ae..9922616 100644 --- a/man/pkg_cache_reset.Rd +++ b/man/pkg_cache_reset.Rd @@ -15,6 +15,7 @@ package.} } \description{ Clears the cached topic index and parsed Rd objects for \code{package}. -Only needed for source packages, and will generally be automatically -be called by roxygen2. +This will generally be called automatically by roxygen2 for source packages. +Installed package indexes are automatically reset when their namespace is +unloaded. } diff --git a/man/pkg_topics.Rd b/man/pkg_topics.Rd index 47e2aa9..148a460 100644 --- a/man/pkg_topics.Rd +++ b/man/pkg_topics.Rd @@ -26,10 +26,8 @@ indexed from the \verb{\\alias\{\}} commands in their source \verb{man/} directo rather than a name. } -Indexes are cached. Installed packages are revalidated against file -modification times on every access. Source packages remain cached until -\code{\link[=pkg_cache_reset]{pkg_cache_reset()}} is called, avoiding repeated scans of their \verb{man/} -directories. +Indexes remain cached until \code{\link[=pkg_cache_reset]{pkg_cache_reset()}} is called. Installed package +indexes are also reset automatically when their namespace is unloaded. For source packages, \verb{\\alias\{\}} extraction is line-based: any number of aliases may appear anywhere on a line, but an alias must open and close diff --git a/tests/testthat/test-pkg.R b/tests/testthat/test-pkg.R index 8df1f0e..4570451 100644 --- a/tests/testthat/test-pkg.R +++ b/tests/testthat/test-pkg.R @@ -117,6 +117,23 @@ test_that("pkg_cache_reset() drops the requested cached index", { expect_equal(pkg_topics("stats")[["rnorm"]], "Normal") }) +test_that("installed package unload hooks drop the cached index", { + package <- "rdtoolsHookTest" + key <- "hook-test" + event <- packageEvent(package, "onUnload") + old_hooks <- getHook(event) + withr::defer(setHook(event, old_hooks, action = "replace")) + + assign(key, new.env(), envir = the$index) + index_register_unload(package, key) + index_register_unload(package, key) + + hooks <- getHook(event) + expect_length(hooks, length(old_hooks) + 1) + hooks[[length(hooks)]]() + expect_equal(intersect(ls(the$index), key), character()) +}) + test_that("pkg_search_path() puts attached packages before base fallbacks", { packages <- pkg_search_path() expect_true("base" %in% packages) From 4917a1b646b7c063e9e8528d3f5ca45cdb893e3f Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 13:22:50 -0500 Subject: [PATCH 05/12] Add `topic_find_package()` & `pkg_search_base()` --- NAMESPACE | 5 +- R/pkg.R | 42 ++++++++++-- R/topic.R | 131 ++++++++++++++++++++++++++++++++---- R/utils.R | 6 ++ man/pkg_search_attached.Rd | 25 +++++++ man/pkg_search_path.Rd | 19 ------ man/topic_find.Rd | 27 +++++--- man/topic_find_package.Rd | 34 ++++++++++ tests/testthat/test-pkg.R | 8 +-- tests/testthat/test-topic.R | 31 ++++++++- 10 files changed, 273 insertions(+), 55 deletions(-) create mode 100644 man/pkg_search_attached.Rd delete mode 100644 man/pkg_search_path.Rd create mode 100644 man/topic_find_package.Rd diff --git a/NAMESPACE b/NAMESPACE index 01fa2c7..c4ac0b5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,9 +1,12 @@ # Generated by roxygen2: do not edit by hand export(pkg_cache_reset) -export(pkg_search_path) +export(pkg_search_attached) export(pkg_topics) +export(pkgs_search_base) export(topic_find) +export(topic_find_all) +export(topic_find_package) export(topic_parse) export(topic_rd) useDynLib(rdtools, .registration = TRUE) diff --git a/R/pkg.R b/R/pkg.R index 856185a..ad7bea8 100644 --- a/R/pkg.R +++ b/R/pkg.R @@ -31,16 +31,44 @@ pkg_topics <- function(package) { #' Packages searched for help by default #' -#' The packages a bare `?topic` can see: every attached package, in search -#' path order, followed by the base packages that are always available. -#' This is the default search set for [topic_find()]. +#' @description +#' `pkg_search_attached()` returns the packages a bare `?topic` can see: every +#' attached package, in search path order, followed by the base packages that +#' are always available. This is the default search set for [topic_find()]. +#' +#' `pkgs_search_base()` returns the base packages that are always searched. #' #' @returns A character vector of package names. #' @export #' @examples -#' pkg_search_path() -pkg_search_path <- function() { +#' pkg_search_attached() +#' pkgs_search_base() +pkg_search_attached <- function() { attached <- sub("^package:", "", grep("^package:", search(), value = TRUE)) - always <- c("datasets", "utils", "grDevices", "graphics", "stats", "base") - unique(c(attached, always)) + unique(c(attached, pkgs_search_base())) +} + +#' @rdname pkg_search_attached +#' @export +pkgs_search_base <- function() { + if (getRversion() >= "4.4.0") { + tools::standard_package_names()[["base"]] + } else { + c( + "base", + "compiler", + "datasets", + "graphics", + "grDevices", + "grid", + "methods", + "parallel", + "splines", + "stats", + "stats4", + "tcltk", + "tools", + "utils" + ) + } } diff --git a/R/topic.R b/R/topic.R index b433bf4..75cb583 100644 --- a/R/topic.R +++ b/R/topic.R @@ -28,37 +28,144 @@ topic_parse <- function(topic) { } } -#' Find which package documents a topic +#' Find packages that document a topic #' -#' Looks `topic` up in each of `packages` in order and returns the first -#' hit. Lookups use the cached per-package indexes built by [pkg_topics()], -#' so scanning even a long search set is cheap. +#' @description +#' `topic_find()` looks `topic` up in each of `packages` in order and returns +#' the first hit. `topic_find_all()` returns every hit. Lookups use the cached +#' per-package indexes built by [pkg_topics()], so scanning even a long search +#' set is cheap. #' #' @param topic A single string naming an alias, matched exactly. #' Use [topic_parse()] first if you need to handle qualified topics like #' `"pkg::foo"`. #' @param packages A character vector of package names (and/or source -#' package paths) to search, in order. Defaults to [pkg_search_path()]. -#' All packages must be findable; an uninstalled package is an error. -#' @returns `NULL` if the topic isn't found; otherwise a list with elements: +#' package paths) to search, in order. Defaults to [pkg_search_attached()]. +#' Unavailable packages are skipped. +#' @returns `topic_find()` returns `NULL` if the topic isn't found; otherwise a +#' list with elements: #' * `package`: the package that documents the topic. #' * `file`: the name of the Rd file (without extension). +#' +#' `topic_find_all()` returns a list of these results, or an empty list if the +#' topic isn't found. #' @export #' @examples #' topic_find("rnorm") -#' topic_find("median", c("dplyr", "stats")) +#' topic_find("mean", c("stats", "base")) +#' topic_find_all("plot", c("graphics", "base")) #' topic_find("no-such-topic") -topic_find <- function(topic, packages = pkg_search_path()) { +topic_find <- function(topic, packages = pkg_search_attached()) { + found <- topic_find_all(topic, packages) + if (length(found) == 0) NULL else found[[1]] +} + +#' @rdname topic_find +#' @export +topic_find_all <- function(topic, packages = pkg_search_attached()) { check_string(topic) + check_character(packages) + found <- list() for (package in packages) { - entry <- index(package) + entry <- tryCatch( + index(package), + packageNotFoundError = function(cnd) NULL + ) + if (is.null(entry)) { + next + } file <- get0(topic, envir = entry$env, inherits = FALSE) if (!is.null(file)) { - return(list(package = entry$name, file = file)) + found[[length(found) + 1]] <- list(package = entry$name, file = file) } } - NULL + found +} + +#' Find the package qualifier for a topic +#' +#' Determines whether a topic needs a package qualifier when linking from the +#' documentation of another package. The current package is checked first, +#' followed by `dependencies`, and then the base packages. Re-exported objects +#' are attributed to their original package. +#' +#' @param topic A single string naming an alias, matched exactly. +#' @param package The name or source directory of the current package. +#' @param dependencies A character vector of dependencies to search. +#' [Base packages][pkgs_search_base] are always included. +#' @returns A character vector with special length and missingness semantics: +#' * `NA_character_` means the topic was found but needs no qualification. +#' * `character()` means the topic was not found. +#' * One package name means the topic has one unambiguous qualifier. +#' * Multiple package names mean the topic is ambiguous. +#' @export +#' @examples +#' topic_find_package("rnorm", "stats", character()) +topic_find_package <- function(topic, package, dependencies) { + check_string(topic) + check_string(package) + check_character(dependencies) + + if (topic_has(package, topic)) { + return(NA_character_) + } + + packages <- unique(c(dependencies, pkgs_search_base())) + matches <- topic_find_all(topic, packages) + found <- vapply(matches, `[[`, character(1), "package") + found <- unique(vapply(found, topic_source, character(1), topic = topic)) + + base <- pkgs_search_base() + if (length(found) == 0) { + character() + } else if (length(found) == 1) { + if (found %in% base) NA_character_ else found + } else if (all(found %in% base)) { + NA_character_ + } else { + found + } +} + +topic_has <- function(package, topic) { + tryCatch( + !is.null(topic_find(topic, package)), + error = function(cnd) FALSE + ) +} + +topic_source <- function(package, topic) { + if (package %in% pkgs_search_base()) { + return(package) + } + + ns <- asNamespace(package) + if (!exists(topic, envir = ns, inherits = TRUE)) { + return(package) + } + + object <- get(topic, envir = ns, inherits = TRUE) + if (is.primitive(object)) { + return("base") + } + if (is.function(object)) { + env <- environment(object) + if (isNamespace(env)) { + return(getNamespaceName(env)) + } + return(package) + } + + imports <- getNamespaceImports(ns) + imports <- imports[names(imports) != ""] + matches <- vapply(imports, `%in%`, logical(1), x = topic) + if (!any(matches)) { + return(package) + } + + packages <- names(matches)[matches] + packages[[length(packages)]] } #' Get the parsed Rd for a topic diff --git a/R/utils.R b/R/utils.R index 9e5332e..c2589bd 100644 --- a/R/utils.R +++ b/R/utils.R @@ -5,3 +5,9 @@ check_string <- function(x, arg = deparse(substitute(x))) { stop(sprintf("`%s` must be a single string.", arg), call. = FALSE) } } + +check_character <- function(x, arg = deparse(substitute(x))) { + if (!is.character(x) || anyNA(x)) { + stop(sprintf("`%s` must be a character vector.", arg), call. = FALSE) + } +} diff --git a/man/pkg_search_attached.Rd b/man/pkg_search_attached.Rd new file mode 100644 index 0000000..1af4d46 --- /dev/null +++ b/man/pkg_search_attached.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/pkg.R +\name{pkg_search_attached} +\alias{pkg_search_attached} +\alias{pkgs_search_base} +\title{Packages searched for help by default} +\usage{ +pkg_search_attached() + +pkgs_search_base() +} +\value{ +A character vector of package names. +} +\description{ +\code{pkg_search_attached()} returns the packages a bare \code{?topic} can see: every +attached package, in search path order, followed by the base packages that +are always available. This is the default search set for \code{\link[=topic_find]{topic_find()}}. + +\code{pkgs_search_base()} returns the base packages that are always searched. +} +\examples{ +pkg_search_attached() +pkgs_search_base() +} diff --git a/man/pkg_search_path.Rd b/man/pkg_search_path.Rd deleted file mode 100644 index dbdcb50..0000000 --- a/man/pkg_search_path.Rd +++ /dev/null @@ -1,19 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/pkg.R -\name{pkg_search_path} -\alias{pkg_search_path} -\title{Packages searched for help by default} -\usage{ -pkg_search_path() -} -\value{ -A character vector of package names. -} -\description{ -The packages a bare \code{?topic} can see: every attached package, in search -path order, followed by the base packages that are always available. -This is the default search set for \code{\link[=topic_find]{topic_find()}}. -} -\examples{ -pkg_search_path() -} diff --git a/man/topic_find.Rd b/man/topic_find.Rd index 97dbb0e..6ccffdf 100644 --- a/man/topic_find.Rd +++ b/man/topic_find.Rd @@ -2,9 +2,12 @@ % Please edit documentation in R/topic.R \name{topic_find} \alias{topic_find} -\title{Find which package documents a topic} +\alias{topic_find_all} +\title{Find packages that document a topic} \usage{ -topic_find(topic, packages = pkg_search_path()) +topic_find(topic, packages = pkg_search_attached()) + +topic_find_all(topic, packages = pkg_search_attached()) } \arguments{ \item{topic}{A single string naming an alias, matched exactly. @@ -12,23 +15,29 @@ Use \code{\link[=topic_parse]{topic_parse()}} first if you need to handle qualif \code{"pkg::foo"}.} \item{packages}{A character vector of package names (and/or source -package paths) to search, in order. Defaults to \code{\link[=pkg_search_path]{pkg_search_path()}}. -All packages must be findable; an uninstalled package is an error.} +package paths) to search, in order. Defaults to \code{\link[=pkg_search_attached]{pkg_search_attached()}}. +Unavailable packages are skipped.} } \value{ -\code{NULL} if the topic isn't found; otherwise a list with elements: +\code{topic_find()} returns \code{NULL} if the topic isn't found; otherwise a +list with elements: \itemize{ \item \code{package}: the package that documents the topic. \item \code{file}: the name of the Rd file (without extension). } + +\code{topic_find_all()} returns a list of these results, or an empty list if the +topic isn't found. } \description{ -Looks \code{topic} up in each of \code{packages} in order and returns the first -hit. Lookups use the cached per-package indexes built by \code{\link[=pkg_topics]{pkg_topics()}}, -so scanning even a long search set is cheap. +\code{topic_find()} looks \code{topic} up in each of \code{packages} in order and returns +the first hit. \code{topic_find_all()} returns every hit. Lookups use the cached +per-package indexes built by \code{\link[=pkg_topics]{pkg_topics()}}, so scanning even a long search +set is cheap. } \examples{ topic_find("rnorm") -topic_find("median", c("dplyr", "stats")) +topic_find("mean", c("stats", "base")) +topic_find_all("plot", c("graphics", "base")) topic_find("no-such-topic") } diff --git a/man/topic_find_package.Rd b/man/topic_find_package.Rd new file mode 100644 index 0000000..3104c63 --- /dev/null +++ b/man/topic_find_package.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/topic.R +\name{topic_find_package} +\alias{topic_find_package} +\title{Find the package qualifier for a topic} +\usage{ +topic_find_package(topic, package, dependencies) +} +\arguments{ +\item{topic}{A single string naming an alias, matched exactly.} + +\item{package}{The name or source directory of the current package.} + +\item{dependencies}{A character vector of dependencies to search. +\link[=pkgs_search_base]{Base packages} are always included.} +} +\value{ +A character vector with special length and missingness semantics: +\itemize{ +\item \code{NA_character_} means the topic was found but needs no qualification. +\item \code{character()} means the topic was not found. +\item One package name means the topic has one unambiguous qualifier. +\item Multiple package names mean the topic is ambiguous. +} +} +\description{ +Determines whether a topic needs a package qualifier when linking from the +documentation of another package. The current package is checked first, +followed by \code{dependencies}, and then the base packages. Re-exported objects +are attributed to their original package. +} +\examples{ +topic_find_package("rnorm", "stats", character()) +} diff --git a/tests/testthat/test-pkg.R b/tests/testthat/test-pkg.R index 4570451..0af665d 100644 --- a/tests/testthat/test-pkg.R +++ b/tests/testthat/test-pkg.R @@ -134,10 +134,8 @@ test_that("installed package unload hooks drop the cached index", { expect_equal(intersect(ls(the$index), key), character()) }) -test_that("pkg_search_path() puts attached packages before base fallbacks", { - packages <- pkg_search_path() - expect_true("base" %in% packages) - expect_true("stats" %in% packages) +test_that("pkg_search_attached() puts attached packages before base fallbacks", { + packages <- pkg_search_attached() + expect_setequal(intersect(packages, pkgs_search_base()), pkgs_search_base()) expect_equal(anyDuplicated(packages), 0L) - expect_equal(packages[[length(packages)]], "base") }) diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index 0486ea6..78a1704 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -46,6 +46,17 @@ test_that("topic_find() respects package order", { expect_equal(found$package, "base") }) +test_that("topic_find_all() returns every match", { + expect_equal( + topic_find_all("plot", c("graphics", "base")), + list( + list(package = "graphics", file = "plot.default"), + list(package = "base", file = "plot") + ) + ) + expect_equal(topic_find_all("not-a-topic", "base"), list()) +}) + test_that("topic_find() returns NULL for missing topics", { expect_null(topic_find("definitely-not-a-topic", "stats")) }) @@ -55,8 +66,12 @@ test_that("topic_find() searches the search path by default", { expect_equal(found$package, "base") }) -test_that("topic_find() errors on uninstalled packages", { - expect_error(topic_find("foo", "not-an-installed-package")) +test_that("topic_find() skips uninstalled packages", { + expect_null(topic_find("foo", "not-an-installed-package")) + expect_equal( + topic_find("rnorm", c("not-an-installed-package", "stats")), + list(package = "stats", file = "Normal") + ) }) test_that("topic_find() finds topics in source packages", { @@ -67,6 +82,18 @@ test_that("topic_find() finds topics in source packages", { ) }) +test_that("topic_find_package() determines package qualification", { + path <- local_test_pkg("foo.Rd" = "foo") + + expect_identical(topic_find_package("foo", path, "withr"), NA_character_) + expect_identical(topic_find_package("rnorm", path, "withr"), NA_character_) + expect_identical(topic_find_package("local_tempdir", path, "withr"), "withr") + expect_identical( + topic_find_package("definitely-not-a-topic", path, "withr"), + character() + ) +}) + test_that("topic_rd() fetches parsed Rd from installed packages", { rd <- topic_rd("rnorm", "stats") expect_s3_class(rd, "Rd") From 84fca54dc107fc2e23c5d97ffe1fe2fd019b043b Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 13:28:01 -0500 Subject: [PATCH 06/12] Add some package path helpers --- R/index.R | 30 +++++++++++++++++++++++++++++- R/topic.R | 12 +++--------- tests/testthat/test-pkg.R | 10 ++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/R/index.R b/R/index.R index 0b1c996..9aef607 100644 --- a/R/index.R +++ b/R/index.R @@ -90,7 +90,7 @@ index_resolve <- function(package) { path <- getNamespaceInfo(asNamespace(package), "path") list(name = package, path = path, backend = "source") } else { - path <- find.package(package) + path <- pkg_find(package) if (file.exists(file.path(path, "help", "aliases.rds"))) { backend <- "installed" } else { @@ -100,6 +100,34 @@ index_resolve <- function(package) { } } +# A minimal find.package() for a single package. Unlike find.package(), this +# avoids reading and validating package metadata from every matching library. +pkg_find <- function(package) { + path <- pkg_find_path(package) + if (is.null(path)) { + stop(packageNotFoundError(package, .libPaths(), call = sys.call())) + } + path +} + +# A minimal rlang::is_installed() for a single package without version checks. +pkg_is_installed <- function(package) { + !is.null(pkg_find_path(package)) +} + +pkg_find_path <- function(package) { + if (package == "base") { + return(system.file()) + } + if (isNamespaceLoaded(package)) { + return(getNamespaceInfo(asNamespace(package), "path")) + } + + paths <- file.path(.libPaths(), package) + found <- which(file.exists(file.path(paths, "DESCRIPTION")))[1] + if (is.na(found)) NULL else paths[[found]] +} + index_build <- function(package, key) { res <- index_resolve(package) diff --git a/R/topic.R b/R/topic.R index 75cb583..d5aafeb 100644 --- a/R/topic.R +++ b/R/topic.R @@ -68,13 +68,10 @@ topic_find_all <- function(topic, packages = pkg_search_attached()) { found <- list() for (package in packages) { - entry <- tryCatch( - index(package), - packageNotFoundError = function(cnd) NULL - ) - if (is.null(entry)) { + if (!is_pkg_path(package) && !pkg_is_installed(package)) { next } + entry <- index(package) file <- get0(topic, envir = entry$env, inherits = FALSE) if (!is.null(file)) { found[[length(found) + 1]] <- list(package = entry$name, file = file) @@ -129,10 +126,7 @@ topic_find_package <- function(topic, package, dependencies) { } topic_has <- function(package, topic) { - tryCatch( - !is.null(topic_find(topic, package)), - error = function(cnd) FALSE - ) + !is.null(topic_find(topic, package)) } topic_source <- function(package, topic) { diff --git a/tests/testthat/test-pkg.R b/tests/testthat/test-pkg.R index 0af665d..0825197 100644 --- a/tests/testthat/test-pkg.R +++ b/tests/testthat/test-pkg.R @@ -9,6 +9,16 @@ test_that("pkg_topics() reads installed packages from aliases.rds", { ) }) +test_that("local package lookup finds installed packages efficiently", { + expect_identical(pkg_find("stats"), find.package("stats")) + expect_identical(pkg_is_installed("stats"), TRUE) + expect_identical(pkg_is_installed("not-an-installed-package"), FALSE) + expect_error( + pkg_find("not-an-installed-package"), + class = "packageNotFoundError" + ) +}) + test_that("pkg_topics() indexes source packages from man/", { path <- local_test_pkg( "foo.Rd" = c("foo", "foo-extra"), From 6498aa701466fb3ab9462350fac5332eade6b63a Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 13:29:09 -0500 Subject: [PATCH 07/12] Export `topic_exists()` --- NAMESPACE | 1 + R/topic.R | 16 ++++++++++++++-- man/topic_exists.Rd | 24 ++++++++++++++++++++++++ tests/testthat/test-topic.R | 6 ++++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 man/topic_exists.Rd diff --git a/NAMESPACE b/NAMESPACE index c4ac0b5..43086d4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,6 +4,7 @@ export(pkg_cache_reset) export(pkg_search_attached) export(pkg_topics) export(pkgs_search_base) +export(topic_exists) export(topic_find) export(topic_find_all) export(topic_find_package) diff --git a/R/topic.R b/R/topic.R index d5aafeb..d4ac80b 100644 --- a/R/topic.R +++ b/R/topic.R @@ -104,7 +104,7 @@ topic_find_package <- function(topic, package, dependencies) { check_string(package) check_character(dependencies) - if (topic_has(package, topic)) { + if (topic_exists(topic, package)) { return(NA_character_) } @@ -125,7 +125,19 @@ topic_find_package <- function(topic, package, dependencies) { } } -topic_has <- function(package, topic) { +#' Does a package document a topic? +#' +#' Checks whether `package` has an exact alias matching `topic`. +#' +#' @param topic A single string naming an alias, matched exactly. +#' @param package A package name or source package path. An unavailable package +#' returns `FALSE`. +#' @returns A single `TRUE` or `FALSE`. +#' @export +#' @examples +#' topic_exists("rnorm", "stats") +#' topic_exists("not-a-topic", "stats") +topic_exists <- function(topic, package) { !is.null(topic_find(topic, package)) } diff --git a/man/topic_exists.Rd b/man/topic_exists.Rd new file mode 100644 index 0000000..2c2771c --- /dev/null +++ b/man/topic_exists.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/topic.R +\name{topic_exists} +\alias{topic_exists} +\title{Does a package document a topic?} +\usage{ +topic_exists(topic, package) +} +\arguments{ +\item{topic}{A single string naming an alias, matched exactly.} + +\item{package}{A package name or source package path. An unavailable package +returns \code{FALSE}.} +} +\value{ +A single \code{TRUE} or \code{FALSE}. +} +\description{ +Checks whether \code{package} has an exact alias matching \code{topic}. +} +\examples{ +topic_exists("rnorm", "stats") +topic_exists("not-a-topic", "stats") +} diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index 78a1704..41238a2 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -61,6 +61,12 @@ test_that("topic_find() returns NULL for missing topics", { expect_null(topic_find("definitely-not-a-topic", "stats")) }) +test_that("topic_exists() detects documented topics", { + expect_identical(topic_exists("rnorm", "stats"), TRUE) + expect_identical(topic_exists("not-a-topic", "stats"), FALSE) + expect_identical(topic_exists("foo", "not-an-installed-package"), FALSE) +}) + test_that("topic_find() searches the search path by default", { found <- topic_find("mean") expect_equal(found$package, "base") From aaea8f3c6971b933662f00a1a24e838ce977b9c8 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 13:30:40 -0500 Subject: [PATCH 08/12] Rename to `topic_qualifier()` --- NAMESPACE | 2 +- R/topic.R | 4 ++-- man/{topic_find_package.Rd => topic_qualifier.Rd} | 8 ++++---- tests/testthat/test-topic.R | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) rename man/{topic_find_package.Rd => topic_qualifier.Rd} (87%) diff --git a/NAMESPACE b/NAMESPACE index 43086d4..c1e228f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -7,7 +7,7 @@ export(pkgs_search_base) export(topic_exists) export(topic_find) export(topic_find_all) -export(topic_find_package) export(topic_parse) +export(topic_qualifier) export(topic_rd) useDynLib(rdtools, .registration = TRUE) diff --git a/R/topic.R b/R/topic.R index d4ac80b..ddbaeb5 100644 --- a/R/topic.R +++ b/R/topic.R @@ -98,8 +98,8 @@ topic_find_all <- function(topic, packages = pkg_search_attached()) { #' * Multiple package names mean the topic is ambiguous. #' @export #' @examples -#' topic_find_package("rnorm", "stats", character()) -topic_find_package <- function(topic, package, dependencies) { +#' topic_qualifier("rnorm", "stats", character()) +topic_qualifier <- function(topic, package, dependencies) { check_string(topic) check_string(package) check_character(dependencies) diff --git a/man/topic_find_package.Rd b/man/topic_qualifier.Rd similarity index 87% rename from man/topic_find_package.Rd rename to man/topic_qualifier.Rd index 3104c63..c1bfbb1 100644 --- a/man/topic_find_package.Rd +++ b/man/topic_qualifier.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/topic.R -\name{topic_find_package} -\alias{topic_find_package} +\name{topic_qualifier} +\alias{topic_qualifier} \title{Find the package qualifier for a topic} \usage{ -topic_find_package(topic, package, dependencies) +topic_qualifier(topic, package, dependencies) } \arguments{ \item{topic}{A single string naming an alias, matched exactly.} @@ -30,5 +30,5 @@ followed by \code{dependencies}, and then the base packages. Re-exported objects are attributed to their original package. } \examples{ -topic_find_package("rnorm", "stats", character()) +topic_qualifier("rnorm", "stats", character()) } diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index 41238a2..a7eee57 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -88,14 +88,14 @@ test_that("topic_find() finds topics in source packages", { ) }) -test_that("topic_find_package() determines package qualification", { +test_that("topic_qualifier() determines package qualification", { path <- local_test_pkg("foo.Rd" = "foo") - expect_identical(topic_find_package("foo", path, "withr"), NA_character_) - expect_identical(topic_find_package("rnorm", path, "withr"), NA_character_) - expect_identical(topic_find_package("local_tempdir", path, "withr"), "withr") + expect_identical(topic_qualifier("foo", path, "withr"), NA_character_) + expect_identical(topic_qualifier("rnorm", path, "withr"), NA_character_) + expect_identical(topic_qualifier("local_tempdir", path, "withr"), "withr") expect_identical( - topic_find_package("definitely-not-a-topic", path, "withr"), + topic_qualifier("definitely-not-a-topic", path, "withr"), character() ) }) From cc3a63b021adc94d7ea1ec31b130daa75b3e1bad Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 13:40:28 -0500 Subject: [PATCH 09/12] Speed up `pkg_is_installed()` --- R/index.R | 3 ++- tests/testthat/test-topic.R | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/R/index.R b/R/index.R index 9aef607..f1c5f95 100644 --- a/R/index.R +++ b/R/index.R @@ -112,7 +112,8 @@ pkg_find <- function(package) { # A minimal rlang::is_installed() for a single package without version checks. pkg_is_installed <- function(package) { - !is.null(pkg_find_path(package)) + exists(package, envir = the$index, inherits = FALSE) || + !is.null(pkg_find_path(package)) } pkg_find_path <- function(package) { diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index a7eee57..ddfbcbb 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -57,6 +57,18 @@ test_that("topic_find_all() returns every match", { expect_equal(topic_find_all("not-a-topic", "base"), list()) }) +test_that("topic_find_all() does not recheck cached packages", { + pkg_topics("stats") + local_mocked_bindings( + pkg_find_path = function(package) stop("unexpected filesystem lookup") + ) + + expect_equal( + topic_find_all("rnorm", "stats"), + list(list(package = "stats", file = "Normal")) + ) +}) + test_that("topic_find() returns NULL for missing topics", { expect_null(topic_find("definitely-not-a-topic", "stats")) }) From 432e9bf5859663669f1eb7c6f97b00d1cf667219 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 16:00:57 -0500 Subject: [PATCH 10/12] Export `pkg_macros()` --- NAMESPACE | 1 + R/topic.R | 23 ++++++++++++++++++----- man/pkg_macros.Rd | 24 ++++++++++++++++++++++++ tests/testthat/test-topic.R | 6 ++++++ 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 man/pkg_macros.Rd diff --git a/NAMESPACE b/NAMESPACE index c1e228f..cc4349d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,7 @@ # Generated by roxygen2: do not edit by hand export(pkg_cache_reset) +export(pkg_macros) export(pkg_search_attached) export(pkg_topics) export(pkgs_search_base) diff --git a/R/topic.R b/R/topic.R index ddbaeb5..db3a5f6 100644 --- a/R/topic.R +++ b/R/topic.R @@ -239,7 +239,7 @@ rd_fetch_installed <- function(pkg_path, package, file) { rd_parse_source <- function(entry, file) { if (is.null(entry$macros)) { - entry$macros <- rd_macros(entry$path) + entry$macros <- pkg_macros(entry$path) } tools::parse_Rd( entry$files[[file]], @@ -248,10 +248,23 @@ rd_parse_source <- function(entry, file) { ) } -# The package's own Rd macros (RdMacros field + man/macros/), layered over -# R's system macros — reproduces what R does at install time. -rd_macros <- function(pkg_path) { - macros <- tools::loadPkgRdMacros(pkg_path) +#' Load a package's Rd macros +#' +#' Loads the macros declared by the package's `RdMacros` field and +#' `man/macros/` directory, layered over R's system macros. This reproduces the +#' macro environment used when installing a package. +#' +#' @param package A package name, or a path to the source directory of a +#' package. +#' @returns An environment containing the Rd macro definitions. +#' @export +#' @examples +#' macros <- pkg_macros("stats") +#' head(ls(macros)) +pkg_macros <- function(package) { + check_string(package) + path <- index_resolve(package)$path + macros <- tools::loadPkgRdMacros(path) tools::loadRdMacros( file.path(R.home("share"), "Rd", "macros", "system.Rd"), macros = macros diff --git a/man/pkg_macros.Rd b/man/pkg_macros.Rd new file mode 100644 index 0000000..f1180ad --- /dev/null +++ b/man/pkg_macros.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/topic.R +\name{pkg_macros} +\alias{pkg_macros} +\title{Load a package's Rd macros} +\usage{ +pkg_macros(package) +} +\arguments{ +\item{package}{A package name, or a path to the source directory of a +package.} +} +\value{ +An environment containing the Rd macro definitions. +} +\description{ +Loads the macros declared by the package's \code{RdMacros} field and +\verb{man/macros/} directory, layered over R's system macros. This reproduces the +macro environment used when installing a package. +} +\examples{ +macros <- pkg_macros("stats") +head(ls(macros)) +} diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index ddfbcbb..0b487d0 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -120,6 +120,12 @@ test_that("topic_rd() fetches parsed Rd from installed packages", { expect_true("\\name" %in% tags) }) +test_that("pkg_macros() loads package and system macros", { + macros <- pkg_macros("stats") + expect_type(macros, "environment") + expect_setequal(intersect(ls(macros), "\\doi"), "\\doi") +}) + test_that("topic_rd() parses Rd from source packages", { path <- local_test_pkg("foo.Rd" = "foo") rd <- topic_rd("foo", path) From a5ec98a328b01990db9c1352f24858b0c3e9b16d Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 16:04:46 -0500 Subject: [PATCH 11/12] Move to correct place --- R/pkg.R | 23 +++++++++++++++++++++++ R/topic.R | 23 ----------------------- man/pkg_macros.Rd | 2 +- tests/testthat/test-pkg.R | 6 ++++++ tests/testthat/test-topic.R | 6 ------ 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/R/pkg.R b/R/pkg.R index ad7bea8..8a74574 100644 --- a/R/pkg.R +++ b/R/pkg.R @@ -29,6 +29,29 @@ pkg_topics <- function(package) { index(package)$topics } +#' Load a package's Rd macros +#' +#' Loads the macros declared by the package's `RdMacros` field and +#' `man/macros/` directory, layered over R's system macros. This reproduces the +#' macro environment used when installing a package. +#' +#' @param package A package name, or a path to the source directory of a +#' package. +#' @returns An environment containing the Rd macro definitions. +#' @export +#' @examples +#' macros <- pkg_macros("stats") +#' head(ls(macros)) +pkg_macros <- function(package) { + check_string(package) + path <- index_resolve(package)$path + macros <- tools::loadPkgRdMacros(path) + tools::loadRdMacros( + file.path(R.home("share"), "Rd", "macros", "system.Rd"), + macros = macros + ) +} + #' Packages searched for help by default #' #' @description diff --git a/R/topic.R b/R/topic.R index db3a5f6..50c288e 100644 --- a/R/topic.R +++ b/R/topic.R @@ -247,26 +247,3 @@ rd_parse_source <- function(entry, file) { encoding = "UTF-8" ) } - -#' Load a package's Rd macros -#' -#' Loads the macros declared by the package's `RdMacros` field and -#' `man/macros/` directory, layered over R's system macros. This reproduces the -#' macro environment used when installing a package. -#' -#' @param package A package name, or a path to the source directory of a -#' package. -#' @returns An environment containing the Rd macro definitions. -#' @export -#' @examples -#' macros <- pkg_macros("stats") -#' head(ls(macros)) -pkg_macros <- function(package) { - check_string(package) - path <- index_resolve(package)$path - macros <- tools::loadPkgRdMacros(path) - tools::loadRdMacros( - file.path(R.home("share"), "Rd", "macros", "system.Rd"), - macros = macros - ) -} diff --git a/man/pkg_macros.Rd b/man/pkg_macros.Rd index f1180ad..cbfec7f 100644 --- a/man/pkg_macros.Rd +++ b/man/pkg_macros.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/topic.R +% Please edit documentation in R/pkg.R \name{pkg_macros} \alias{pkg_macros} \title{Load a package's Rd macros} diff --git a/tests/testthat/test-pkg.R b/tests/testthat/test-pkg.R index 0825197..14d7a9e 100644 --- a/tests/testthat/test-pkg.R +++ b/tests/testthat/test-pkg.R @@ -9,6 +9,12 @@ test_that("pkg_topics() reads installed packages from aliases.rds", { ) }) +test_that("pkg_macros() loads package and system macros", { + macros <- pkg_macros("stats") + expect_type(macros, "environment") + expect_setequal(intersect(ls(macros), "\\doi"), "\\doi") +}) + test_that("local package lookup finds installed packages efficiently", { expect_identical(pkg_find("stats"), find.package("stats")) expect_identical(pkg_is_installed("stats"), TRUE) diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index 0b487d0..ddfbcbb 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -120,12 +120,6 @@ test_that("topic_rd() fetches parsed Rd from installed packages", { expect_true("\\name" %in% tags) }) -test_that("pkg_macros() loads package and system macros", { - macros <- pkg_macros("stats") - expect_type(macros, "environment") - expect_setequal(intersect(ls(macros), "\\doi"), "\\doi") -}) - test_that("topic_rd() parses Rd from source packages", { path <- local_test_pkg("foo.Rd" = "foo") rd <- topic_rd("foo", path) From a6fa1daf746b8d4fc36a462f090315e3bae8dfdc Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 6 Jul 2026 16:58:34 -0500 Subject: [PATCH 12/12] Export topic_source --- NAMESPACE | 1 + R/topic.R | 23 ++++++++++++++++++++--- man/topic_source.Rd | 24 ++++++++++++++++++++++++ tests/testthat/test-topic.R | 5 +++++ 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 man/topic_source.Rd diff --git a/NAMESPACE b/NAMESPACE index cc4349d..eea4c0c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,4 +11,5 @@ export(topic_find_all) export(topic_parse) export(topic_qualifier) export(topic_rd) +export(topic_source) useDynLib(rdtools, .registration = TRUE) diff --git a/R/topic.R b/R/topic.R index 50c288e..cf3a132 100644 --- a/R/topic.R +++ b/R/topic.R @@ -111,7 +111,9 @@ topic_qualifier <- function(topic, package, dependencies) { packages <- unique(c(dependencies, pkgs_search_base())) matches <- topic_find_all(topic, packages) found <- vapply(matches, `[[`, character(1), "package") - found <- unique(vapply(found, topic_source, character(1), topic = topic)) + found <- unique( + vapply(found, \(package) topic_source(topic, package), character(1)) + ) base <- pkgs_search_base() if (length(found) == 0) { @@ -141,7 +143,22 @@ topic_exists <- function(topic, package) { !is.null(topic_find(topic, package)) } -topic_source <- function(package, topic) { +#' Find the source package for a topic +#' +#' Determines which package supplies the object associated with a documented +#' topic. This resolves re-exported functions and imported objects to the +#' package where they originate. +#' +#' @param topic A single string naming a topic. +#' @param package A package name. +#' @returns A single package name. +#' @export +#' @examples +#' topic_source("rnorm", "stats") +topic_source <- function(topic, package) { + check_string(topic) + check_string(package) + if (package %in% pkgs_search_base()) { return(package) } @@ -158,7 +175,7 @@ topic_source <- function(package, topic) { if (is.function(object)) { env <- environment(object) if (isNamespace(env)) { - return(getNamespaceName(env)) + return(unname(getNamespaceName(env))) } return(package) } diff --git a/man/topic_source.Rd b/man/topic_source.Rd new file mode 100644 index 0000000..a59ff6f --- /dev/null +++ b/man/topic_source.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/topic.R +\name{topic_source} +\alias{topic_source} +\title{Find the source package for a topic} +\usage{ +topic_source(topic, package) +} +\arguments{ +\item{topic}{A single string naming a topic.} + +\item{package}{A package name.} +} +\value{ +A single package name. +} +\description{ +Determines which package supplies the object associated with a documented +topic. This resolves re-exported functions and imported objects to the +package where they originate. +} +\examples{ +topic_source("rnorm", "stats") +} diff --git a/tests/testthat/test-topic.R b/tests/testthat/test-topic.R index ddfbcbb..fa861d3 100644 --- a/tests/testthat/test-topic.R +++ b/tests/testthat/test-topic.R @@ -79,6 +79,11 @@ test_that("topic_exists() detects documented topics", { expect_identical(topic_exists("foo", "not-an-installed-package"), FALSE) }) +test_that("topic_source() finds the package supplying an object", { + expect_identical(topic_source("rnorm", "stats"), "stats") + expect_identical(topic_source("local_tempdir", "withr"), "withr") +}) + test_that("topic_find() searches the search path by default", { found <- topic_find("mean") expect_equal(found$package, "base")