From 15c2e0e5de66c0d0da54de060bf6582e8be66975 Mon Sep 17 00:00:00 2001 From: chiricom Date: Sat, 17 Oct 2026 23:07:24 +0000 Subject: [PATCH 1/9] typo: .class, not class --- R/cli-errors.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/cli-errors.R b/R/cli-errors.R index 1d2c70d29..e4fc4e34e 100644 --- a/R/cli-errors.R +++ b/R/cli-errors.R @@ -19,7 +19,7 @@ cli_error <- function( if (length(.data)) { cnd[names(.data)] <- .data } - if (length(class)) { + if (length(.class)) { class(cnd) <- c(.class, class(cnd)) } From bec88aadf405b8d64aaf1def231ba5c51c67be0c Mon Sep 17 00:00:00 2001 From: chiricom Date: Tue, 13 Oct 2026 20:54:31 +0000 Subject: [PATCH 2/9] Gemini: New cli_extract_vars for metaprogramming on {cli} strings --- NAMESPACE | 1 + NEWS.md | 3 ++ R/inline.R | 77 +++++++++++++++++++++++---- _pkgdown.yml | 1 + man/cli_extract_vars.Rd | 72 +++++++++++++++++++++++++ tests/testthat/_snaps/substitution.md | 4 +- tests/testthat/test-inline-2.R | 69 ++++++++++++++++++++++++ 7 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 man/cli_extract_vars.Rd diff --git a/NAMESPACE b/NAMESPACE index db38cd37c..c03482e7c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -96,6 +96,7 @@ export(cli_code) export(cli_div) export(cli_dl) export(cli_end) +export(cli_extract_vars) export(cli_fmt) export(cli_format) export(cli_format_method) diff --git a/NEWS.md b/NEWS.md index b185e4ebf..660391f46 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,8 @@ # cli (development version) +* New `cli_extract_vars()` function to extract variable and function names + from cli format strings without evaluating them (#750). + * `cli_progress_bar()` has a new argument `quiet`. If TRUE, then progress bar will be suppressed. diff --git a/R/inline.R b/R/inline.R index 112fb7a84..376a24e53 100644 --- a/R/inline.R +++ b/R/inline.R @@ -283,12 +283,15 @@ clii__inline <- function(app, text, .list) { inline_regex <- function() "(?s)^[.]([-[:alnum:]_]+)[[:space:]]+(.*)" -make_cmd_transformer <- function(values, .call = NULL) { +make_cmd_transformer <- function(values, .call = NULL, .eval = TRUE) { values$marker <- random_id() values$qty <- NA_integer_ values$num_subst <- 0L values$postprocess <- FALSE values$pmarkers <- list() + if (!.eval) { + values$exprs <- expression() + } # These are common because of purrr's default argument names, so we # hardcode them es exceptions. They are in packages @@ -365,12 +368,17 @@ make_cmd_transformer <- function(values, .call = NULL) { "Could not parse cli {.code {{}}} expression: {.code {abbrev(code, 20)}}." ) - res <- eval(expr, envir = envir) %??% - cli_error( - call. = caller, - "Could not evaluate cli {.code {{}}} expression: - {.code {abbrev(code, 20)}}." - ) + if (.eval) { + res <- eval(expr, envir = envir) %??% + cli_error( + call. = caller, + "Could not evaluate cli {.code {{}}} expression: + {.code {abbrev(code, 20)}}." + ) + } else { + values$exprs <- c(values$exprs, expr) + res <- 1L + } id <- paste0("v", length(values)) values[[id]] <- res @@ -381,10 +389,11 @@ make_cmd_transformer <- function(values, .call = NULL) { } } -glue_cmd <- function(..., .envir, .call = sys.call(-1), .trim = TRUE) { +glue_cmd <- function(..., .envir, .call = sys.call(-1), .trim = TRUE, + .eval = TRUE) { str <- paste0(unlist(list(...), use.names = FALSE), collapse = "") values <- new.env(parent = emptyenv()) - transformer <- make_cmd_transformer(values, .call = .call) + transformer <- make_cmd_transformer(values, .call = .call, .eval = .eval) pstr <- glue( str, .envir = .envir, @@ -398,6 +407,56 @@ glue_cmd <- function(..., .envir, .call = sys.call(-1), .trim = TRUE) { ) } +#' Extract variable and function names from cli format strings +#' +#' Parses cli inline markup and glue substitutions in `text` without +#' evaluating the embedded R expressions, returning the symbol names +#' referenced inside `{...}` interpolation blocks via [base::all.vars()]. +#' +#' Static analysis tools (such as `lintr::object_usage_linter()`) can use +#' `cli_extract_vars()` to discover variables and functions referenced in +#' cli calls. +#' +#' If `text` is a named character vector (for example, as passed to +#' [cli_dl()], [cli_li()], [cli_bullets()], or [cli_abort()]), both +#' `names(text)` and `text` are inspected. +#' +#' @param text Character vector of cli format strings. +#' @param functions Logical scalar passed to [base::all.vars()]: whether to +#' include function names in addition to variable names. Defaults to +#' `TRUE`. +#' @param unique Logical scalar passed to [base::all.vars()]: whether to +#' deduplicate extracted symbol names. Defaults to `TRUE`. +#' @return Character vector of extracted symbol names. +#' +#' @seealso This function supports [inline markup][inline-markup]. +#' @family functions supporting inline markup +#' @export +#' @examples +#' cli_extract_vars( +#' "{.arg x} has {length(x)} elements, but it should be {.emph {length(y)}}" +#' ) +#' cli_extract_vars( +#' "{.arg x} has {length(x)} elements, but it should be {.emph {length(y)}}", +#' functions = FALSE +#' ) + +cli_extract_vars <- function(text, functions = TRUE, unique = TRUE) { + if (length(text) == 0L) { + return(character()) + } + nms <- names(text) + text <- as.character(text) + if (!is.null(nms)) { + text <- as.vector(rbind(nms, text)) + } + call <- sys.call() + exprs <- lapply(text, function(x) { + glue_cmd(x, .envir = emptyenv(), .call = call, .eval = FALSE)$values$exprs + }) + all.vars(do.call(c, exprs), functions = functions, unique = unique) +} + glue_no_cmd <- function(...) { str <- paste0(unlist(list(...), use.names = FALSE), collapse = "") values <- new.env(parent = emptyenv()) diff --git a/_pkgdown.yml b/_pkgdown.yml index dc4d2f4b4..799a9e91a 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -230,6 +230,7 @@ reference: - cat_bullet - cli-config - cli_debug_doc + - cli_extract_vars - cli_fmt - cat_line - cat_print diff --git a/man/cli_extract_vars.Rd b/man/cli_extract_vars.Rd new file mode 100644 index 000000000..a8b23ebc7 --- /dev/null +++ b/man/cli_extract_vars.Rd @@ -0,0 +1,72 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/inline.R +\name{cli_extract_vars} +\alias{cli_extract_vars} +\title{Extract variable and function names from cli format strings} +\usage{ +cli_extract_vars(text, functions = TRUE, unique = TRUE) +} +\arguments{ +\item{text}{Character vector of cli format strings.} + +\item{functions}{Logical scalar passed to \code{\link[base:all.vars]{base::all.vars()}}: whether to +include function names in addition to variable names. Defaults to +\code{TRUE}.} + +\item{unique}{Logical scalar passed to \code{\link[base:all.vars]{base::all.vars()}}: whether to +deduplicate extracted symbol names. Defaults to \code{TRUE}.} +} +\value{ +Character vector of extracted symbol names. +} +\description{ +Parses cli inline markup and glue substitutions in \code{text} without +evaluating the embedded R expressions, returning the symbol names +referenced inside \code{{...}} interpolation blocks via \code{\link[base:all.vars]{base::all.vars()}}. +} +\details{ +Static analysis tools (such as \code{lintr::object_usage_linter()}) can use +\code{cli_extract_vars()} to discover variables and functions referenced in +cli calls. + +If \code{text} is a named character vector (for example, as passed to +\code{\link[=cli_dl]{cli_dl()}}, \code{\link[=cli_li]{cli_li()}}, \code{\link[=cli_bullets]{cli_bullets()}}, or \code{\link[=cli_abort]{cli_abort()}}), both +\code{names(text)} and \code{text} are inspected. +} +\examples{ +cli_extract_vars( + "{.arg x} has {length(x)} elements, but it should be {.emph {length(y)}}" +) +cli_extract_vars( + "{.arg x} has {length(x)} elements, but it should be {.emph {length(y)}}", + functions = FALSE +) +} +\seealso{ +This function supports \link[=inline-markup]{inline markup}. + +Other functions supporting inline markup: +\code{\link[=cli_abort]{cli_abort()}}, +\code{\link[=cli_alert]{cli_alert()}}, +\code{\link[=cli_blockquote]{cli_blockquote()}}, +\code{\link[=cli_bullets]{cli_bullets()}}, +\code{\link[=cli_bullets_raw]{cli_bullets_raw()}}, +\code{\link[=cli_dl]{cli_dl()}}, +\code{\link[=cli_h1]{cli_h1()}}, +\code{\link[=cli_li]{cli_li()}}, +\code{\link[=cli_ol]{cli_ol()}}, +\code{\link[=cli_process_start]{cli_process_start()}}, +\code{\link[=cli_progress_along]{cli_progress_along()}}, +\code{\link[=cli_progress_bar]{cli_progress_bar()}}, +\code{\link[=cli_progress_message]{cli_progress_message()}}, +\code{\link[=cli_progress_output]{cli_progress_output()}}, +\code{\link[=cli_progress_step]{cli_progress_step()}}, +\code{\link[=cli_rule]{cli_rule()}}, +\code{\link[=cli_status]{cli_status()}}, +\code{\link[=cli_status_update]{cli_status_update()}}, +\code{\link[=cli_text]{cli_text()}}, +\code{\link[=cli_ul]{cli_ul()}}, +\code{\link[=format_error]{format_error()}}, +\code{\link[=format_inline]{format_inline()}} +} +\concept{functions supporting inline markup} diff --git a/tests/testthat/_snaps/substitution.md b/tests/testthat/_snaps/substitution.md index 184e4a5bc..b04b23001 100644 --- a/tests/testthat/_snaps/substitution.md +++ b/tests/testthat/_snaps/substitution.md @@ -5,14 +5,14 @@ Condition Error: ! ! Could not evaluate cli `{}` expression: ` asdfasdfasdf `. - Caused by error in `eval(expr, envir = envir)` at inline.R:368:: + Caused by error in `eval(expr, envir = envir)` at inline.R:372:: ! object 'asdfasdfasdf' not found Code cli_text("foo {cmd {dsfsdf()}}") Condition Error: ! ! Could not parse cli `{}` expression: `cmd {dsfsdf()}`. - Caused by error in `parse(text = code, keep.source = FALSE)` at inline.R:362:: + Caused by error in `parse(text = code, keep.source = FALSE)` at inline.R:365:: ! :1:5: unexpected '{' 1: cmd { ^ diff --git a/tests/testthat/test-inline-2.R b/tests/testthat/test-inline-2.R index 0b9da4770..a617cdc6b 100644 --- a/tests/testthat/test-inline-2.R +++ b/tests/testthat/test-inline-2.R @@ -212,3 +212,72 @@ test_that(".num", { format_inline("--- {.num {1:4 * 10000}} ---") }) }) + +test_that("cli_extract_vars", { + msg <- "{.arg z} has {length(x)} elements, but it should be {.emph {length(y)}}" + expect_identical(cli_extract_vars(msg), c("length", "x", "y")) + expect_identical(cli_extract_vars(msg, functions = FALSE), c("x", "y")) + expect_identical( + cli_extract_vars(msg, unique = FALSE), + c("length", "x", "length", "y") + ) + expect_identical( + cli_extract_vars(msg, functions = FALSE, unique = FALSE), + c("x", "y") + ) + + # Deeply nested styles and hyperlink styles + expect_identical( + cli_extract_vars("{.pkg {.emph {pkg}}} and {.href [text {a}](https://x.com/{b})}"), + c("pkg", "a", "b") + ) + + # Pluralization directives and helpers + expect_identical( + cli_extract_vars("Found {n} file{?s} and {?no/one/some} {ndir} director{?y/ies}."), + c("n", "ndir") + ) + expect_identical( + cli_extract_vars("Will remove {?no/the/the} {pkgs} package{?s}."), + "pkgs" + ) + expect_identical( + cli_extract_vars("{nupd}/{ntotal} {qty(nupd)} file{?s} and {no(nfile)} dir{?s}"), + c("nupd", "ntotal", "qty", "no", "nfile") + ) + + # Escaped braces, empty braces, and purrr dot exceptions + expect_identical( + cli_extract_vars("Escaped {{ignored}} and {.code {{also_ignored}}} and {} with {used}"), + "used" + ) + expect_identical( + cli_extract_vars("{.x} and {.y} and {.}"), + c(".x", ".y", ".") + ) + + # Character vectors and named character vectors (bullets and dl/li) + expect_identical( + cli_extract_vars(c("Header {a}", "i" = "Info {.val {b}}", "x" = "Error {c}")), + c("a", "b", "c") + ) + expect_identical( + cli_extract_vars(c("{.field {term1}}" = "Means {desc1}", "{term2}" = "{desc2}")), + c("term1", "desc1", "term2", "desc2") + ) + + # Edge cases: empty, NULL, NA, plain text, and non-evaluation + expect_identical(cli_extract_vars(character()), character()) + expect_identical(cli_extract_vars(NULL), character()) + expect_identical(cli_extract_vars(""), character()) + expect_identical(cli_extract_vars("plain text {.arg x}"), character()) + expect_identical(cli_extract_vars(NA_character_), character()) + expect_identical(cli_extract_vars(c("hello {a}", NA_character_)), "a") + expect_identical(cli_extract_vars("{stop(not_evaluated)}"), c("stop", "not_evaluated")) + + # Syntax and markup errors + expect_error(cli_extract_vars("unclosed {a"), "Expecting '\\}'") + expect_error(cli_extract_vars("bad {a + }"), "Could not parse cli") + expect_error(cli_extract_vars("bad {.foobar}"), "Invalid cli literal") + expect_error(cli_extract_vars("bad {?s}"), "Cannot pluralize without a quantity") +}) From b8846cc0ab1a075a3fb920b3aab20f5e728bf95c Mon Sep 17 00:00:00 2001 From: chiricom Date: Tue, 13 Oct 2026 21:16:50 +0000 Subject: [PATCH 3/9] self-cite in NEWS --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 660391f46..8e204eff6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,7 @@ # cli (development version) * New `cli_extract_vars()` function to extract variable and function names - from cli format strings without evaluating them (#750). + from cli format strings without evaluating them (#750, @MichaelChirico). * `cli_progress_bar()` has a new argument `quiet`. If TRUE, then progress bar will be suppressed. From ff84a41a200d16827338067f5eaa8ba398ce11f5 Mon Sep 17 00:00:00 2001 From: chiricom Date: Tue, 13 Oct 2026 21:19:58 +0000 Subject: [PATCH 4/9] allow wider function definition --- R/inline.R | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/R/inline.R b/R/inline.R index 376a24e53..f10ec3457 100644 --- a/R/inline.R +++ b/R/inline.R @@ -389,8 +389,7 @@ make_cmd_transformer <- function(values, .call = NULL, .eval = TRUE) { } } -glue_cmd <- function(..., .envir, .call = sys.call(-1), .trim = TRUE, - .eval = TRUE) { +glue_cmd <- function(..., .envir, .call = sys.call(-1), .trim = TRUE, .eval = TRUE) { str <- paste0(unlist(list(...), use.names = FALSE), collapse = "") values <- new.env(parent = emptyenv()) transformer <- make_cmd_transformer(values, .call = .call, .eval = .eval) From 155e8d9c233b1f561e56f975370a346bf5dcc9a3 Mon Sep 17 00:00:00 2001 From: chiricom Date: Tue, 13 Oct 2026 21:24:03 +0000 Subject: [PATCH 5/9] Gemini: simplify --- R/inline.R | 6 +----- tests/testthat/test-inline-2.R | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/R/inline.R b/R/inline.R index f10ec3457..e3db6b592 100644 --- a/R/inline.R +++ b/R/inline.R @@ -444,11 +444,7 @@ cli_extract_vars <- function(text, functions = TRUE, unique = TRUE) { if (length(text) == 0L) { return(character()) } - nms <- names(text) - text <- as.character(text) - if (!is.null(nms)) { - text <- as.vector(rbind(nms, text)) - } + text <- c(names(text), as.character(text)) call <- sys.call() exprs <- lapply(text, function(x) { glue_cmd(x, .envir = emptyenv(), .call = call, .eval = FALSE)$values$exprs diff --git a/tests/testthat/test-inline-2.R b/tests/testthat/test-inline-2.R index a617cdc6b..d6a7ca6b2 100644 --- a/tests/testthat/test-inline-2.R +++ b/tests/testthat/test-inline-2.R @@ -263,7 +263,7 @@ test_that("cli_extract_vars", { ) expect_identical( cli_extract_vars(c("{.field {term1}}" = "Means {desc1}", "{term2}" = "{desc2}")), - c("term1", "desc1", "term2", "desc2") + c("term1", "term2", "desc1", "desc2") ) # Edge cases: empty, NULL, NA, plain text, and non-evaluation From d075b1b3b2237f0c9dab28d73a985549d8dc48a3 Mon Sep 17 00:00:00 2001 From: chiricom Date: Tue, 13 Oct 2026 21:30:28 +0000 Subject: [PATCH 6/9] appease air --- R/inline.R | 8 +++++++- tests/testthat/test-inline-2.R | 37 ++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/R/inline.R b/R/inline.R index e3db6b592..4473c9f81 100644 --- a/R/inline.R +++ b/R/inline.R @@ -389,7 +389,13 @@ make_cmd_transformer <- function(values, .call = NULL, .eval = TRUE) { } } -glue_cmd <- function(..., .envir, .call = sys.call(-1), .trim = TRUE, .eval = TRUE) { +glue_cmd <- function( + ..., + .envir, + .call = sys.call(-1), + .trim = TRUE, + .eval = TRUE +) { str <- paste0(unlist(list(...), use.names = FALSE), collapse = "") values <- new.env(parent = emptyenv()) transformer <- make_cmd_transformer(values, .call = .call, .eval = .eval) diff --git a/tests/testthat/test-inline-2.R b/tests/testthat/test-inline-2.R index d6a7ca6b2..03d1a09d2 100644 --- a/tests/testthat/test-inline-2.R +++ b/tests/testthat/test-inline-2.R @@ -228,13 +228,17 @@ test_that("cli_extract_vars", { # Deeply nested styles and hyperlink styles expect_identical( - cli_extract_vars("{.pkg {.emph {pkg}}} and {.href [text {a}](https://x.com/{b})}"), + cli_extract_vars( + "{.pkg {.emph {pkg}}} and {.href [text {a}](https://x.com/{b})}" + ), c("pkg", "a", "b") ) # Pluralization directives and helpers expect_identical( - cli_extract_vars("Found {n} file{?s} and {?no/one/some} {ndir} director{?y/ies}."), + cli_extract_vars( + "Found {n} file{?s} and {?no/one/some} {ndir} director{?y/ies}." + ), c("n", "ndir") ) expect_identical( @@ -242,13 +246,17 @@ test_that("cli_extract_vars", { "pkgs" ) expect_identical( - cli_extract_vars("{nupd}/{ntotal} {qty(nupd)} file{?s} and {no(nfile)} dir{?s}"), + cli_extract_vars( + "{nupd}/{ntotal} {qty(nupd)} file{?s} and {no(nfile)} dir{?s}" + ), c("nupd", "ntotal", "qty", "no", "nfile") ) # Escaped braces, empty braces, and purrr dot exceptions expect_identical( - cli_extract_vars("Escaped {{ignored}} and {.code {{also_ignored}}} and {} with {used}"), + cli_extract_vars( + "Escaped {{ignored}} and {.code {{also_ignored}}} and {} with {used}" + ), "used" ) expect_identical( @@ -258,11 +266,18 @@ test_that("cli_extract_vars", { # Character vectors and named character vectors (bullets and dl/li) expect_identical( - cli_extract_vars(c("Header {a}", "i" = "Info {.val {b}}", "x" = "Error {c}")), + cli_extract_vars(c( + "Header {a}", + "i" = "Info {.val {b}}", + "x" = "Error {c}" + )), c("a", "b", "c") ) expect_identical( - cli_extract_vars(c("{.field {term1}}" = "Means {desc1}", "{term2}" = "{desc2}")), + cli_extract_vars(c( + "{.field {term1}}" = "Means {desc1}", + "{term2}" = "{desc2}" + )), c("term1", "term2", "desc1", "desc2") ) @@ -273,11 +288,17 @@ test_that("cli_extract_vars", { expect_identical(cli_extract_vars("plain text {.arg x}"), character()) expect_identical(cli_extract_vars(NA_character_), character()) expect_identical(cli_extract_vars(c("hello {a}", NA_character_)), "a") - expect_identical(cli_extract_vars("{stop(not_evaluated)}"), c("stop", "not_evaluated")) + expect_identical( + cli_extract_vars("{stop(not_evaluated)}"), + c("stop", "not_evaluated") + ) # Syntax and markup errors expect_error(cli_extract_vars("unclosed {a"), "Expecting '\\}'") expect_error(cli_extract_vars("bad {a + }"), "Could not parse cli") expect_error(cli_extract_vars("bad {.foobar}"), "Invalid cli literal") - expect_error(cli_extract_vars("bad {?s}"), "Cannot pluralize without a quantity") + expect_error( + cli_extract_vars("bad {?s}"), + "Cannot pluralize without a quantity" + ) }) From 5d485b7377dca7d8643cb0648bf030b2eeddd3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 15 Oct 2026 09:40:26 +0200 Subject: [PATCH 7/9] Docs: do not put cli_extract_vars in inline-markup family --- R/inline.R | 9 ++++----- man/cli_extract_vars.Rd | 35 ++++------------------------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/R/inline.R b/R/inline.R index 4473c9f81..0275a1302 100644 --- a/R/inline.R +++ b/R/inline.R @@ -414,9 +414,10 @@ glue_cmd <- function( #' Extract variable and function names from cli format strings #' -#' Parses cli inline markup and glue substitutions in `text` without -#' evaluating the embedded R expressions, returning the symbol names -#' referenced inside `{...}` interpolation blocks via [base::all.vars()]. +#' Parses cli [inline markup][inline-markup] and glue substitutions in +#' `text` without evaluating the embedded R expressions, returning the +#' symbol names referenced inside `{...}` interpolation blocks via +#' [base::all.vars()]. #' #' Static analysis tools (such as `lintr::object_usage_linter()`) can use #' `cli_extract_vars()` to discover variables and functions referenced in @@ -434,8 +435,6 @@ glue_cmd <- function( #' deduplicate extracted symbol names. Defaults to `TRUE`. #' @return Character vector of extracted symbol names. #' -#' @seealso This function supports [inline markup][inline-markup]. -#' @family functions supporting inline markup #' @export #' @examples #' cli_extract_vars( diff --git a/man/cli_extract_vars.Rd b/man/cli_extract_vars.Rd index a8b23ebc7..225fe80d0 100644 --- a/man/cli_extract_vars.Rd +++ b/man/cli_extract_vars.Rd @@ -20,9 +20,10 @@ deduplicate extracted symbol names. Defaults to \code{TRUE}.} Character vector of extracted symbol names. } \description{ -Parses cli inline markup and glue substitutions in \code{text} without -evaluating the embedded R expressions, returning the symbol names -referenced inside \code{{...}} interpolation blocks via \code{\link[base:all.vars]{base::all.vars()}}. +Parses cli \link[=inline-markup]{inline markup} and glue substitutions in +\code{text} without evaluating the embedded R expressions, returning the +symbol names referenced inside \code{{...}} interpolation blocks via +\code{\link[base:all.vars]{base::all.vars()}}. } \details{ Static analysis tools (such as \code{lintr::object_usage_linter()}) can use @@ -42,31 +43,3 @@ cli_extract_vars( functions = FALSE ) } -\seealso{ -This function supports \link[=inline-markup]{inline markup}. - -Other functions supporting inline markup: -\code{\link[=cli_abort]{cli_abort()}}, -\code{\link[=cli_alert]{cli_alert()}}, -\code{\link[=cli_blockquote]{cli_blockquote()}}, -\code{\link[=cli_bullets]{cli_bullets()}}, -\code{\link[=cli_bullets_raw]{cli_bullets_raw()}}, -\code{\link[=cli_dl]{cli_dl()}}, -\code{\link[=cli_h1]{cli_h1()}}, -\code{\link[=cli_li]{cli_li()}}, -\code{\link[=cli_ol]{cli_ol()}}, -\code{\link[=cli_process_start]{cli_process_start()}}, -\code{\link[=cli_progress_along]{cli_progress_along()}}, -\code{\link[=cli_progress_bar]{cli_progress_bar()}}, -\code{\link[=cli_progress_message]{cli_progress_message()}}, -\code{\link[=cli_progress_output]{cli_progress_output()}}, -\code{\link[=cli_progress_step]{cli_progress_step()}}, -\code{\link[=cli_rule]{cli_rule()}}, -\code{\link[=cli_status]{cli_status()}}, -\code{\link[=cli_status_update]{cli_status_update()}}, -\code{\link[=cli_text]{cli_text()}}, -\code{\link[=cli_ul]{cli_ul()}}, -\code{\link[=format_error]{format_error()}}, -\code{\link[=format_inline]{format_inline()}} -} -\concept{functions supporting inline markup} From 61a8cb6fd5932c36277dbb0c801390f37f46bfde Mon Sep 17 00:00:00 2001 From: chiricom Date: Sat, 17 Oct 2026 23:04:44 +0000 Subject: [PATCH 8/9] Gemini: Add custom cli_error condition classes to string validation errors --- R/glue.R | 13 ++++++++- R/inline.R | 8 +++++- R/pluralize.R | 12 ++++++--- man/cli_extract_vars.Rd | 6 ++++- tests/testthat/_snaps/substitution.md | 4 +-- tests/testthat/test-inline-2.R | 38 ++++++++++++++++++++++----- 6 files changed, 67 insertions(+), 14 deletions(-) diff --git a/R/glue.R b/R/glue.R index fefd9d70c..2642d162a 100644 --- a/R/glue.R +++ b/R/glue.R @@ -31,11 +31,22 @@ glue <- function( text <- trim(text) } + in_transformer <- FALSE f <- function(expr) { + in_transformer <<- TRUE + on.exit(in_transformer <<- FALSE) eval_func <- as.character(.transformer(expr, .envir) %||% character()) } - res <- .Call(glue_, text, f, .open, .close, .cli) + res <- withCallingHandlers( + .Call(glue_, text, f, .open, .close, .cli), + error = function(e) { + if (!in_transformer) { + class(e) <- c("cli_glue_error", "cli_error", class(e)) + stop(e) + } + } + ) res <- drop_null(res) if (any(lengths(res) == 0L)) { diff --git a/R/inline.R b/R/inline.R index 0275a1302..dead0cd9b 100644 --- a/R/inline.R +++ b/R/inline.R @@ -332,6 +332,7 @@ make_cmd_transformer <- function(values, .call = NULL, .eval = TRUE) { if (!has_match) { throw(cli_error( call. = caller, + .class = c("cli_style_error", "cli_error"), "Invalid cli literal: {.code {{{abbrev(code, 10)}}}} starts with a dot.", "i" = "Interpreted literals must not start with a dot in cli >= 3.4.0.", "i" = paste( @@ -365,6 +366,7 @@ make_cmd_transformer <- function(values, .call = NULL, .eval = TRUE) { expr <- parse(text = code, keep.source = FALSE) %??% cli_error( call. = caller, + .class = c("cli_parse_error", "cli_error"), "Could not parse cli {.code {{}}} expression: {.code {abbrev(code, 20)}}." ) @@ -421,7 +423,11 @@ glue_cmd <- function( #' #' Static analysis tools (such as `lintr::object_usage_linter()`) can use #' `cli_extract_vars()` to discover variables and functions referenced in -#' cli calls. +#' cli calls. Malformed cli strings or R expressions signal an error of +#' class `"cli_error"` (with subclasses `"cli_glue_error"`, +#' `"cli_style_error"`, `"cli_parse_error"`, and +#' `"cli_pluralization_error"`), so static analysis callers should wrap +#' `cli_extract_vars()` in [base::tryCatch()]. #' #' If `text` is a named character vector (for example, as passed to #' [cli_dl()], [cli_li()], [cli_bullets()], or [cli_abort()]), both diff --git a/R/pluralize.R b/R/pluralize.R index bf449d93e..b20083d2b 100644 --- a/R/pluralize.R +++ b/R/pluralize.R @@ -83,6 +83,12 @@ parse_plural <- function(code, values) { } } +stop_pluralization <- function(..., call. = sys.call(-1)) { + cnd <- simpleError(.makeMessage(...), call = call.) + class(cnd) <- c("cli_pluralization_error", "cli_error", class(cnd)) + stop(cnd) +} + process_plural <- function(qty, code) { parts <- strsplit(str_tail(code), "/", fixed = TRUE)[[1]] if (last_character(code) == "/") { @@ -101,7 +107,7 @@ process_plural <- function(qty, code) { parts[3] } } else { - stop("Invalid pluralization directive: `", code, "`") + stop_pluralization("Invalid pluralization directive: `", code, "`") } } @@ -110,10 +116,10 @@ post_process_plurals <- function(str, values) { return(str) } if (values$num_subst == 0) { - stop("Cannot pluralize without a quantity") + stop_pluralization("Cannot pluralize without a quantity") } if (values$num_subst != 1) { - stop("Multiple quantities for pluralization") + stop_pluralization("Multiple quantities for pluralization") } qty <- make_quantity(values$qty) diff --git a/man/cli_extract_vars.Rd b/man/cli_extract_vars.Rd index 225fe80d0..8e2e45059 100644 --- a/man/cli_extract_vars.Rd +++ b/man/cli_extract_vars.Rd @@ -28,7 +28,11 @@ symbol names referenced inside \code{{...}} interpolation blocks via \details{ Static analysis tools (such as \code{lintr::object_usage_linter()}) can use \code{cli_extract_vars()} to discover variables and functions referenced in -cli calls. +cli calls. Malformed cli strings or R expressions signal an error of +class \code{"cli_error"} (with subclasses \code{"cli_glue_error"}, +\code{"cli_style_error"}, \code{"cli_parse_error"}, and +\code{"cli_pluralization_error"}), so static analysis callers should wrap +\code{cli_extract_vars()} in \code{\link[base:conditions]{base::tryCatch()}}. If \code{text} is a named character vector (for example, as passed to \code{\link[=cli_dl]{cli_dl()}}, \code{\link[=cli_li]{cli_li()}}, \code{\link[=cli_bullets]{cli_bullets()}}, or \code{\link[=cli_abort]{cli_abort()}}), both diff --git a/tests/testthat/_snaps/substitution.md b/tests/testthat/_snaps/substitution.md index b04b23001..6f5f950b3 100644 --- a/tests/testthat/_snaps/substitution.md +++ b/tests/testthat/_snaps/substitution.md @@ -5,14 +5,14 @@ Condition Error: ! ! Could not evaluate cli `{}` expression: ` asdfasdfasdf `. - Caused by error in `eval(expr, envir = envir)` at inline.R:372:: + Caused by error in `eval(expr, envir = envir)` at inline.R:374:: ! object 'asdfasdfasdf' not found Code cli_text("foo {cmd {dsfsdf()}}") Condition Error: ! ! Could not parse cli `{}` expression: `cmd {dsfsdf()}`. - Caused by error in `parse(text = code, keep.source = FALSE)` at inline.R:365:: + Caused by error in `parse(text = code, keep.source = FALSE)` at inline.R:366:: ! :1:5: unexpected '{' 1: cmd { ^ diff --git a/tests/testthat/test-inline-2.R b/tests/testthat/test-inline-2.R index 03d1a09d2..3146f7394 100644 --- a/tests/testthat/test-inline-2.R +++ b/tests/testthat/test-inline-2.R @@ -294,11 +294,37 @@ test_that("cli_extract_vars", { ) # Syntax and markup errors - expect_error(cli_extract_vars("unclosed {a"), "Expecting '\\}'") - expect_error(cli_extract_vars("bad {a + }"), "Could not parse cli") - expect_error(cli_extract_vars("bad {.foobar}"), "Invalid cli literal") - expect_error( - cli_extract_vars("bad {?s}"), - "Cannot pluralize without a quantity" + expect_s3_class( + expect_error(cli_extract_vars("unclosed {a"), "Expecting '\\}'"), + c("cli_glue_error", "cli_error") + ) + expect_s3_class( + expect_error(cli_extract_vars("bad {a + }"), "Could not parse cli"), + c("cli_parse_error", "cli_error") + ) + expect_s3_class( + expect_error(cli_extract_vars("bad {.foobar}"), "Invalid cli literal"), + c("cli_style_error", "cli_error") + ) + expect_s3_class( + expect_error( + cli_extract_vars("bad {?s}"), + "Cannot pluralize without a quantity" + ), + c("cli_pluralization_error", "cli_error") + ) + expect_s3_class( + expect_error( + cli_extract_vars("{?s} {a} and {b}"), + "Multiple quantities for pluralization" + ), + c("cli_pluralization_error", "cli_error") + ) + expect_s3_class( + expect_error( + cli_extract_vars("{a} {?a/b/c/d}"), + "Invalid pluralization directive" + ), + c("cli_pluralization_error", "cli_error") ) }) From eb374a25be8b2506932a1aac720378610f268d09 Mon Sep 17 00:00:00 2001 From: chiricom Date: Sat, 17 Oct 2026 23:14:49 +0000 Subject: [PATCH 9/9] Gemini: Use expect_error(class = ...) directly --- tests/testthat/test-inline-2.R | 51 ++++++++++++++++------------------ 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/tests/testthat/test-inline-2.R b/tests/testthat/test-inline-2.R index 3146f7394..8fb92487a 100644 --- a/tests/testthat/test-inline-2.R +++ b/tests/testthat/test-inline-2.R @@ -294,37 +294,34 @@ test_that("cli_extract_vars", { ) # Syntax and markup errors - expect_s3_class( - expect_error(cli_extract_vars("unclosed {a"), "Expecting '\\}'"), - c("cli_glue_error", "cli_error") + expect_error( + cli_extract_vars("unclosed {a"), + "Expecting '\\}'", + class = "cli_glue_error" ) - expect_s3_class( - expect_error(cli_extract_vars("bad {a + }"), "Could not parse cli"), - c("cli_parse_error", "cli_error") + expect_error( + cli_extract_vars("bad {a + }"), + "Could not parse cli", + class = "cli_parse_error" ) - expect_s3_class( - expect_error(cli_extract_vars("bad {.foobar}"), "Invalid cli literal"), - c("cli_style_error", "cli_error") + expect_error( + cli_extract_vars("bad {.foobar}"), + "Invalid cli literal", + class = "cli_style_error" ) - expect_s3_class( - expect_error( - cli_extract_vars("bad {?s}"), - "Cannot pluralize without a quantity" - ), - c("cli_pluralization_error", "cli_error") + expect_error( + cli_extract_vars("bad {?s}"), + "Cannot pluralize without a quantity", + class = "cli_pluralization_error" ) - expect_s3_class( - expect_error( - cli_extract_vars("{?s} {a} and {b}"), - "Multiple quantities for pluralization" - ), - c("cli_pluralization_error", "cli_error") + expect_error( + cli_extract_vars("{?s} {a} and {b}"), + "Multiple quantities for pluralization", + class = "cli_pluralization_error" ) - expect_s3_class( - expect_error( - cli_extract_vars("{a} {?a/b/c/d}"), - "Invalid pluralization directive" - ), - c("cli_pluralization_error", "cli_error") + expect_error( + cli_extract_vars("{a} {?a/b/c/d}"), + "Invalid pluralization directive", + class = "cli_pluralization_error" ) })