diff --git a/R/ansi-hyperlink.R b/R/ansi-hyperlink.R index 209691721..09310de61 100644 --- a/R/ansi-hyperlink.R +++ b/R/ansi-hyperlink.R @@ -130,19 +130,17 @@ make_link_fun <- function(txt) { if (!any(todo)) return(txt) sprt <- ansi_hyperlink_types()$help - if (sprt) { - scheme <- if (identical(attr(sprt, "type"), "rstudio")) { - "ide:help" - } else { - "x-r-help" - } - - txt[todo] <- style_hyperlink( - text = txt[todo], - url = paste0(scheme, ":", txt[todo]) - ) + if (!sprt) { + return(txt) } + fmt <- get_hyperlink_format("help") + # the format has a placeholder for 'topic' + topic <- txt[todo] + done <- style_hyperlink(text = topic, url = glue(fmt)) + + txt[todo] <- done + txt } @@ -151,21 +149,16 @@ make_link_fun <- function(txt) { make_link_help <- function(txt) { mch <- re_match(txt, "^\\[(?.*)\\]\\((?.*)\\)$") text <- ifelse(is.na(mch$text), txt, mch$text) - url <- ifelse(is.na(mch$url), txt, mch$url) + topic <- ifelse(is.na(mch$url), txt, mch$url) sprt <- ansi_hyperlink_types()$help - if (sprt) { - scheme <- if (identical(attr(sprt, "type"), "rstudio")) { - "ide:help" - } else { - "x-r-help" - } - style_hyperlink(text = text, url = paste0(scheme, ":", url)) - - } else { - url2 <- vcapply(url, function(url1) format_inline("{.fun ?{url1}}")) - ifelse(text == url, url2, paste0(text, " (", url2, ")")) + if (!sprt) { + topic2 <- vcapply(topic, function(x) format_inline("{.fun ?{x}}")) + return(ifelse(text == topic, topic2, paste0(text, " (", topic2, ")"))) } + + fmt <- get_hyperlink_format("help") + style_hyperlink(text = text, url = glue(fmt)) } # -- {.href} -------------------------------------------------------------- @@ -193,20 +186,15 @@ make_link_href <- function(txt) { make_link_run <- function(txt) { mch <- re_match(txt, "^\\[(?.*)\\]\\((?.*)\\)$") text <- ifelse(is.na(mch$text), txt, mch$text) - url <- ifelse(is.na(mch$url), txt, mch$url) + code <- ifelse(is.na(mch$url), txt, mch$url) sprt <- ansi_hyperlink_types()$run - if (sprt) { - scheme <- if (identical(attr(sprt, "type"), "rstudio")) { - "ide:run" - } else { - "x-r-run" - } - style_hyperlink(text = text, url = paste0(scheme, ":", url)) - - } else { - vcapply(text, function(url1) format_inline("{.code {url1}}")) + if (!sprt) { + return(vcapply(text, function(code1) format_inline("{.code {code1}}"))) } + + fmt <- get_hyperlink_format("run") + style_hyperlink(text = text, url = glue(fmt)) } # -- {.topic} ------------------------------------------------------------- @@ -214,21 +202,16 @@ make_link_run <- function(txt) { make_link_topic <- function(txt) { mch <- re_match(txt, "^\\[(?.*)\\]\\((?.*)\\)$") text <- ifelse(is.na(mch$text), txt, mch$text) - url <- ifelse(is.na(mch$url), txt, mch$url) + topic <- ifelse(is.na(mch$url), txt, mch$url) sprt <- ansi_hyperlink_types()$help - if (sprt) { - scheme <- if (identical(attr(sprt, "type"), "rstudio")) { - "ide:help" - } else { - "x-r-help" - } - style_hyperlink(text = text, url = paste0(scheme, ":", url)) - - } else { - url2 <- vcapply(url, function(url1) format_inline("{.code ?{url1}}")) - ifelse(text == url, url2, paste0(text, " (", url2, ")")) + if (!sprt) { + topic2 <- vcapply(topic, function(x) format_inline("{.code ?{x}}")) + return(ifelse(text == topic, topic2, paste0(text, " (", topic2, ")"))) } + + fmt <- get_hyperlink_format("help") + style_hyperlink(text = text, url = glue(fmt)) } # -- {.url} --------------------------------------------------------------- @@ -245,21 +228,16 @@ make_link_url <- function(txt) { make_link_vignette <- function(txt) { mch <- re_match(txt, "^\\[(?.*)\\]\\((?.*)\\)$") text <- ifelse(is.na(mch$text), txt, mch$text) - url <- ifelse(is.na(mch$url), txt, mch$url) + vignette <- ifelse(is.na(mch$url), txt, mch$url) sprt <- ansi_hyperlink_types()$vignette - if (sprt) { - scheme <- if (identical(attr(sprt, "type"), "rstudio")) { - "ide:vignette" - } else { - "x-r-vignette" - } - style_hyperlink(text = text, url = paste0(scheme, ":", url)) - - } else { - url2 <- vcapply(url, function(url1) format_inline("{.code vignette({url1})}")) - ifelse(text == url, url2, paste0(text, " (", url2, ")")) + if (!sprt) { + vignette2 <- vcapply(vignette, function(x) format_inline("{.code vignette({x})}")) + return(ifelse(text == vignette, vignette2, paste0(text, " (", vignette2, ")"))) } + + fmt <- get_hyperlink_format("vignette") + style_hyperlink(text = text, url = glue(fmt)) } #' Terminal Hyperlinks @@ -426,3 +404,43 @@ ansi_hyperlink_types <- function() { ) } } + +get_hyperlink_format <- function(type = c("run", "help", "vignette")) { + type <- match.arg(type) + + key <- glue("hyperlink_{type}_url_format") + sprt <- ansi_hyperlink_types()[[type]] + + custom_fmt <- get_config_chr(key) + if (is.null(custom_fmt)) { + if (identical(attr(sprt, "type"), "rstudio")) { + fmt_type <- "rstudio" + } else { + fmt_type <- "standard" + } + } else { + fmt_type <- "custom" + } + + variable <- c(run = "code", help = "topic", vignette = "vignette") + fmt <- switch( + fmt_type, + custom = custom_fmt, + rstudio = glue("ide:{type}:{{{variable[type]}}}"), + standard = glue("x-r-{type}:{{{variable[type]}}}") + ) + fmt +} + +get_config_chr <- function(x, default = NULL) { + opt <- getOption(paste0("cli.", tolower(x))) + if (!is.null(opt)) { + stopifnot(is_string(opt)) + return(opt) + } + + env <- Sys.getenv(paste0("R_CLI_", toupper(x)), NA_character_) + if (!is.na(env)) return(env) + + default +} diff --git a/R/test.R b/R/test.R index 3d7c3167d..9430c04b5 100644 --- a/R/test.R +++ b/R/test.R @@ -112,6 +112,14 @@ test_that_cli <- function(desc, code, cli.hyperlink_help = links, cli.hyperlink_run = links, cli.hyperlink_vignette = links, + cli.hyperlink_run_url_format = NULL, + cli.hyperlink_help_url_format = NULL, + cli.hyperlink_vignette_url_format = NULL + ) + withr::local_envvar( + R_CLI_HYPERLINK_RUN_URL_FORMAT = NA_character_, + R_CLI_HYPERLINK_HELP_URL_FORMAT = NA_character_, + R_CLI_HYPERLINK_VIGNETTE_URL_FORMAT = NA_character_ ) code_ }, c(conf, list(code_ = code))) @@ -131,6 +139,9 @@ local_clean_cli_context <- function(.local_envir = parent.frame()) { cli.hyperlink_run = NULL, cli.hyperlink_help = NULL, cli.hyperlink_vignette = NULL, + cli.hyperlink_run_url_format = NULL, + cli.hyperlink_help_url_format = NULL, + cli.hyperlink_vignette_url_format = NULL, cli.num_colors = NULL, cli.palette = NULL, crayon.enabled = NULL @@ -138,6 +149,12 @@ local_clean_cli_context <- function(.local_envir = parent.frame()) { withr::local_envvar( .local_envir = .local_envir, R_CLI_HYPERLINKS = NA_character_, + R_CLI_HYPERLINK_RUN = NA_character_, + R_CLI_HYPERLINK_HELP = NA_character_, + R_CLI_HYPERLINK_VIGNETTE = NA_character_, + R_CLI_HYPERLINK_RUN_URL_FORMAT = NA_character_, + R_CLI_HYPERLINK_HELP_URL_FORMAT = NA_character_, + R_CLI_HYPERLINK_VIGNETTE_URL_FORMAT = NA_character_, RSTUDIO_CLI_HYPERLINKS = NA_character_, R_CLI_NUM_COLORS = NA_character_, NO_COLOR = NA_character_, diff --git a/tests/testthat/_snaps/links.md b/tests/testthat/_snaps/links.md index 65d8d8425..e42524e30 100644 --- a/tests/testthat/_snaps/links.md +++ b/tests/testthat/_snaps/links.md @@ -819,6 +819,13 @@ Message `pkg::func()` +# .fun with custom format [plain-all] + + Code + cli_text("{.fun pkg::func}") + Message + `]8;;aaa-pkg::func-zzzpkg::func]8;;()` + # {.help} [plain-none] Code @@ -857,6 +864,13 @@ Message ]8;;x-r-help:pkg::fun1pkg::fun1]8;;, ]8;;x-r-help:pkg::fun2pkg::fun2]8;;, and ]8;;x-r-help:pkg::fun3pkg::fun3]8;; +# .help with custom format [plain-all] + + Code + cli_text("{.help pkg::fun}") + Message + ]8;;aaa-pkg::fun-zzzpkg::fun]8;; + # {.href} [plain-none] Code @@ -943,6 +957,13 @@ Message ]8;;x-r-run:pkg::fun1()pkg::fun1()]8;;, ]8;;x-r-run:pkg::fun2()pkg::fun2()]8;;, and ]8;;x-r-run:pkg::fun3()pkg::fun3()]8;; +# .run with custom format [plain-all] + + Code + cli_text("{.run devtools::document()}") + Message + ]8;;aaa-devtools::document()-zzzdevtools::document()]8;; + # {.topic} [plain-none] Code @@ -981,6 +1002,13 @@ Message ]8;;x-r-help:pkg::topic1pkg::topic1]8;;, ]8;;x-r-help:pkg::topic2pkg::topic2]8;;, and ]8;;x-r-help:pkg::topic3pkg::topic3]8;; +# .topic with custom format [plain-all] + + Code + cli_text("{.topic pkg::fun}") + Message + ]8;;aaa-pkg::fun-zzzpkg::fun]8;; + # {.url} [plain-none] Code @@ -1092,3 +1120,10 @@ Message ]8;;x-r-vignette:pkg::topic1pkg::topic1]8;;, ]8;;x-r-vignette:pkg::topic2pkg::topic2]8;;, and ]8;;x-r-vignette:pkg::topic3pkg::topic3]8;; +# .vignette with custom format [plain-all] + + Code + cli_text("{.vignette pkgdown::accessibility}") + Message + ]8;;aaa-pkgdown::accessibility-zzzpkgdown::accessibility]8;; + diff --git a/tests/testthat/test-ansi-hyperlink.R b/tests/testthat/test-ansi-hyperlink.R index 8b4de70f3..7569bbb92 100644 --- a/tests/testthat/test-ansi-hyperlink.R +++ b/tests/testthat/test-ansi-hyperlink.R @@ -240,6 +240,7 @@ test_that("iterm file links", { }) test_that("rstudio links", { + local_clean_cli_context() withr::local_envvar( RSTUDIO = "1", RSTUDIO_SESSION_PID = Sys.getpid(), @@ -370,3 +371,54 @@ test_that("ansi_hyperlink_types", { ) expect_true(ansi_hyperlink_types()[["run"]]) }) + +test_that("get_config_chr() consults option, env var, then its default", { + local_clean_cli_context() + + key <- "hyperlink_TYPE_url_format" + + expect_null(get_config_chr(key)) + + withr::local_envvar(R_CLI_HYPERLINK_TYPE_URL_FORMAT = "envvar") + expect_equal(get_config_chr(key), "envvar") + + withr::local_options(cli.hyperlink_type_url_format = "option") + expect_equal(get_config_chr(key), "option") +}) + +test_that("get_config_chr() errors if option is not NULL or string", { + withr::local_options(cli.something = FALSE) + + expect_error(get_config_chr("something"), "is_string") +}) + +test_that("get_hyperlink_format() delivers custom format", { + local_clean_cli_context() + + withr::local_options( + cli.hyperlink_run = TRUE, + cli.hyperlink_help = TRUE, + cli.hyperlink_vignette = TRUE + ) + + # env var is consulted after option, so start with env var + withr::local_envvar( + R_CLI_HYPERLINK_RUN_URL_FORMAT = "envvar{code}", + R_CLI_HYPERLINK_HELP_URL_FORMAT = "envvar{topic}", + R_CLI_HYPERLINK_VIGNETTE_URL_FORMAT = "envvar{vignette}" + ) + + expect_equal(get_hyperlink_format("run"), "envvar{code}") + expect_equal(get_hyperlink_format("help"), "envvar{topic}") + expect_equal(get_hyperlink_format("vignette"), "envvar{vignette}") + + withr::local_options( + cli.hyperlink_run_url_format = "option{code}", + cli.hyperlink_help_url_format = "option{topic}", + cli.hyperlink_vignette_url_format = "option{vignette}" + ) + + expect_equal(get_hyperlink_format("run"), "option{code}") + expect_equal(get_hyperlink_format("help"), "option{topic}") + expect_equal(get_hyperlink_format("vignette"), "option{vignette}") +}) diff --git a/tests/testthat/test-links.R b/tests/testthat/test-links.R index 3b52fb26a..aec64cace 100644 --- a/tests/testthat/test-links.R +++ b/tests/testthat/test-links.R @@ -135,6 +135,13 @@ test_that_cli(configs = "plain", links = "all", "turning off help", { }) }) +test_that_cli(configs = "plain", links = "all", ".fun with custom format", { + withr::local_options(cli.hyperlink_help_url_format = "aaa-{topic}-zzz") + expect_snapshot({ + cli_text("{.fun pkg::func}") + }) +}) + # -- {.help} -------------------------------------------------------------- test_that_cli(configs = "plain", links = c("all", "none"), @@ -151,6 +158,13 @@ test_that_cli(configs = "plain", links = c("all", "none"), }) }) +test_that_cli(configs = "plain", links = "all", ".help with custom format", { + withr::local_options(cli.hyperlink_help_url_format = "aaa-{topic}-zzz") + expect_snapshot({ + cli_text("{.help pkg::fun}") + }) +}) + # -- {.href} -------------------------------------------------------------- test_that_cli(configs = "plain", links = c("all", "none"), @@ -188,6 +202,13 @@ test_that_cli(configs = "plain", links = c("all", "none"), }) }) +test_that_cli(configs = "plain", links = "all", ".run with custom format", { + withr::local_options(cli.hyperlink_run_url_format = "aaa-{code}-zzz") + expect_snapshot({ + cli_text("{.run devtools::document()}") + }) +}) + # -- {.topic} ------------------------------------------------------------- test_that_cli(configs = "plain", links = c("all", "none"), @@ -204,6 +225,13 @@ test_that_cli(configs = "plain", links = c("all", "none"), }) }) +test_that_cli(configs = "plain", links = "all", ".topic with custom format", { + withr::local_options(cli.hyperlink_help_url_format = "aaa-{topic}-zzz") + expect_snapshot({ + cli_text("{.topic pkg::fun}") + }) +}) + # -- {.url} --------------------------------------------------------------- test_that_cli(configs = c("plain", "fancy"), links = c("all", "none"), @@ -252,3 +280,10 @@ test_that_cli(configs = "plain", links = c("all", "none"), cli_text("{.vignette {vignettes}}") }) }) + +test_that_cli(configs = "plain", links = "all", ".vignette with custom format", { + withr::local_options(cli.hyperlink_vignette_url_format = "aaa-{vignette}-zzz") + expect_snapshot({ + cli_text("{.vignette pkgdown::accessibility}") + }) +}) diff --git a/tests/testthat/test-progress-types.R b/tests/testthat/test-progress-types.R index 58f2b10cf..2df654815 100644 --- a/tests/testthat/test-progress-types.R +++ b/tests/testthat/test-progress-types.R @@ -9,7 +9,8 @@ test_that("iterator", { cli.spinner = NULL, cli.spinner_unicode = NULL, cli.progress_format_iterator = NULL, - cli.progress_format_iterator_nototal= NULL + cli.progress_format_iterator_nototal= NULL, + cli.width = Inf ) fun <- function() { diff --git a/vignettes/cli-config-internal.Rmd b/vignettes/cli-config-internal.Rmd index ae946001e..0b662de38 100644 --- a/vignettes/cli-config-internal.Rmd +++ b/vignettes/cli-config-internal.Rmd @@ -9,7 +9,7 @@ editor_options: wrap: sentence --- -These are environment variables and options are for cli developers, users +These environment variables and options are for cli developers. Users should not rely on them as they may change between cli releases. ## Internal environment variables diff --git a/vignettes/cli-config-user.Rmd b/vignettes/cli-config-user.Rmd index 6dfe66c3d..da65b1e40 100644 --- a/vignettes/cli-config-user.Rmd +++ b/vignettes/cli-config-user.Rmd @@ -9,18 +9,81 @@ editor_options: wrap: sentence --- -These are environment variables and options that uses may set, to modify +These are environment variables and options that users may set, to modify the behavior of cli. -## User facing environment variables +## Hyperlinks + +cli uses ANSI escape sequences to create hyperlinks. +Specifically, cli creates [OSC 8 hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) that have this syntax: + +``` +OSC 8 ; {OPTIONAL PARAMS } ; {URI} ST {LINK TEXT} OSC 8 ; ; ST +``` + +Under the hood, [style_hyperlink()] is the helper that forms these links, but it is more common to request them indirectly via inline markup (documented in `help("links")`). ### `R_CLI_HYPERLINK_MODE` Set to `posix` to force generating POSIX compatible ANSI hyperlinks. +This is about the specific operating system command (OSC) and string terminator (ST) used in hyperlinks. + If not set, then RStudio compatible links are generated. This is a temporary crutch until RStudio handles POSIX hyperlinks correctly, and after that it will be removed. +### `cli.hyperlink` option and `R_CLI_HYPERLINKS` env var + +Set this option or env var to `true`, `TRUE` or `True` to tell cli that the terminal supports ANSI hyperlinks. +Leave this configuration unset (or set to anything else) when there is no hyperlink support. + +The option `cli.hyperlink` takes precedence over the `R_CLI_HYPERLINKS` env var. + +### `cli.hyperlink_*` options and `R_CLI_HYPERLINK_*` env vars + +cli supports a few special types of hyperlink that don't just point to, e.g., a webpage or a file. +These specialized hyperlinks cause R-specific actions to happen, such executing a bit of R code or opening specific documentation. + +Set the relevant option or env var to `true`, `TRUE`, or `True` to tell cli that the terminal is capable of implementing these specialized behaviours. +Leave this configuration unset (or set to anything else) when there is no support for a specific type of hyperlink. + +| Action | Example inline markup | Option | Env var | +|-------------------|---------------------------------------------------------------------------------------|--------------------------|----------------------------| +| Run R code | `{.run testthat::snapshot_review()}` | `cli.hyperlink_run` | `R_CLI_HYPERLINK_RUN` | +| Open a help topic | `{.fun stats::lm}` `{.topic tibble::tibble_options}` `{.help [{.fun lm}](stats::lm)}` | `cli.hyperlink_help` | `R_CLI_HYPERLINK_HELP` | +| Open a vignette | `{.vignette tibble::types}` | `cli.hyperlink_vignette` | `R_CLI_HYPERLINK_VIGNETTE` | + +In all cases, the option takes priority over the corresponding env var. + +### `cli.hyperlink_*_url_format` options and `R_CLI_HYPERLINK_*_URL_FORMAT` env vars + +Recall the overall structure of cli's hyperlinks: + +``` +OSC 8 ; {OPTIONAL PARAMS } ; {URI} ST {LINK TEXT} OSC 8 ; ; ST +``` + +The `URI` part has a default format for each type of hyperlink, but it is possible to provide a custom format via an option or an env var. +If defined, the option takes priority over the env var. + +| Action | Default URI format | Customize via option | Customize via env var | +|-------------------|---------------------------|-------------------------------------|---------------------------------------| +| Run R code | `x-r-run:{code}` | `cli.hyperlink_run_url_format` | `R_CLI_HYPERLINK_RUN_URL_FORMAT` | +| Open a help topic | `x-r-help:{topic}` | `cli.hyperlink_help_url_format` | `R_CLI_HYPERLINK_HELP_URL_FORMAT` | +| Open a vignette | `x-r-vignette:{vignette}` | `cli.hyperlink_vignette_url_format` | `R_CLI_HYPERLINK_VIGNETTE_URL_FORMAT` | + +A format must be a glue-like template with the relevant placeholder in curly braces (`code`, `topic` or `vignette`). + +Here's an example of a custom URI format for runnable code, which is useful in an integrated Positron terminal: + +``` +positron://positron.positron-r/cli?command=x-r-run:{code} +``` + +(For backwards compatibility with older versions of RStudio, in some contexts, a legacy format is used, e.g. `ide:run:{code}`.) + +## User facing environment variables + ### `NO_COLOR` Set to a nonempty value to turn off ANSI colors. @@ -43,13 +106,6 @@ See [is_dynamic_tty()]. Set to a positive integer to assume a given number of colors. See [num_ansi_colors()]. -### `R_CLI_HYPERLINKS` - -Set to `true`, `TRUE` or `True` to tell cli that the terminal supports -ANSI hyperlinks. -Set to anything else to assume no hyperlink support. -See [style_hyperlink()]. - ## User facing options ### `cli.ansi` @@ -107,13 +163,6 @@ See [is_dynamic_tty()]. Whether the cli status bar should try to hide the cursor on terminals. Set the `FALSE` if the hidden cursor causes issues. -### `cli.hyperlink` - -Set to `true`, `TRUE` or `True` to tell cli that the terminal supports -ANSI hyperlinks. -Set to anything else to assume no hyperlink support. -See [style_hyperlink()]. - ### `cli.ignore_unknown_rstudio_theme` Set to `TRUE` to omit a warning for an unknown RStudio theme in