After writing about 20 R packages, I found I had accumulated several utility functions that I used across different packages. This package extracts them so they can be used openly instead of buried as internal helpers. This vignette covers a curated subset; for a full list see help(package = 'xfun') or https://github.com/yihui/xfun.
R’s $ operator uses partial matching on lists, which can silently return the wrong element. strict_list() and as_strict_list() disable this:
z = strict_list(aaa = "I am aaa", b = 1:5)
z$a # NULL — no partial match
#> NULL
z$aaa # correct element
#> [1] "I am aaa"
z2 = unclass(z) # ordinary list
z2$a # "I am aaa" — partial match fires!
#> [1] "I am aaa"
z3 = as_strict_list(z2)
z3$a # NULL again
#> NULL
The same surprise can happen with attr(). attr2() is an alias for attr(exact = TRUE):
x = structure(list(), foobar = 42)
base::attr(x, "foo") # 42 — partial match
#> [1] 42
attr2(x, "foo") # NULL
#> NULL
attr2(x, "foobar") # 42
#> [1] 42
raw_string() suppresses indices, quotes, and escape sequences so you see the actual content:
x = c("line one", "say \"hello\"", "tab\there")
x # with indices and escapes
#> [1] "line one" "say \"hello\"" "tab\there"
raw_string(x) # what the strings actually contain
line one
say "hello"
tab here
file_string() reads a file and returns a single string, printing it cleanly as a side effect:
f = system.file("LICENSE", package = "xfun")
file_string(f)
YEAR: 2018-2026
COPYRIGHT HOLDER: Yihui Xie
str() for nested objectstree() draws a tree diagram from str() output, replacing .. depth markers with vertical pipes so parent–child relationships are clearer:
fit = lm(dist ~ speed, data = cars)
tree(fit)
List of 12
|-coefficients : Named num [1:2] -17.58 3.93
| - attr(*, "names")= chr [1:2] "(Intercept)" "speed"
|-residuals : Named num [1:50] 3.85 11.85 -5.95 12.05 2.12 ...
| - attr(*, "names")= chr [1:50] "1" "2" "3" "4" ...
|-effects : Named num [1:50] -303.914 145.552 -8.115 9.885 0.194 ...
| - attr(*, "names")= chr [1:50] "(Intercept)" "speed" "" "" ...
|-rank : int 2
|-fitted.values: Named num [1:50] -1.85 -1.85 9.95 9.95 13.88 ...
| - attr(*, "names")= chr [1:50] "1" "2" "3" "4" ...
|-assign : int [1:2] 0 1
|-qr :List of 5
| |-qr : num [1:50, 1:2] -7.071 0.141 0.141 0.141 0.141 ...
| | - attr(*, "dimnames")=List of 2
| | |-: chr [1:50] "1" "2" "3" "4" ...
| | |-: chr [1:2] "(Intercept)" "speed"
| | - attr(*, "assign")= int [1:2] 0 1
| |-qraux: num [1:2] 1.14 1.27
| |-pivot: int [1:2] 1 2
| |-tol : num 1e-07
| |-rank : int 2
| - attr(*, "class")= chr "qr"
|-df.residual : int 48
|-xlevels : Named list()
|-call : language lm(formula = dist ~ speed, data = cars)
|-terms :Classes 'terms', 'formula' language dist ~ speed
| - attr(*, "variables")= language list(dist, speed)
| - attr(*, "factors")= int [1:2, 1] 0 1
| - attr(*, "dimnames")=List of 2
| |-: chr [1:2] "dist" "speed"
| |-: chr "speed"
| - attr(*, "term.labels")= chr "speed"
| - attr(*, "order")= int 1
| - attr(*, "intercept")= int 1
| - attr(*, "response")= int 1
| - attr(*, ".Environment")=<environment: R_GlobalEnv>
| - attr(*, "predvars")= language list(dist, speed)
| - attr(*, "dataClasses")= Named chr [1:2] "numeric" "numeric"
| - attr(*, "names")= chr [1:2] "dist" "speed"
|-model :'data.frame': 50 obs. of 2 variables:
|-dist : num [1:50] 2 10 4 22 16 10 18 26 34 17 ...
|-speed: num [1:50] 4 4 7 7 8 9 10 10 10 11 ...
- attr(*, "terms")=Classes 'terms', 'formula' language dist ~ speed
- attr(*, "variables")= language list(dist, speed)
- attr(*, "factors")= int [1:2, 1] 0 1
- attr(*, "dimnames")=List of 2
|-: chr [1:2] "dist" "speed"
|-: chr "speed"
- attr(*, "term.labels")= chr "speed"
- attr(*, "order")= int 1
- attr(*, "intercept")= int 1
- attr(*, "response")= int 1
- attr(*, ".Environment")=<environment: R_GlobalEnv>
- attr(*, "predvars")= language list(dist, speed)
- attr(*, "dataClasses")= Named chr [1:2] "numeric" "numeric"
- attr(*, "names")= chr [1:2] "dist" "speed"
- attr(*, "class")= chr "lm"
tabset() renders a list as interactive Markdown tabsets (requires litedown’s tabset support). Each list member becomes a tab; nested lists become nested tabsets. By default each tab shows str() output, but you can pass any function:
tabset(iris) # one tab per column
tabset(iris, print) # print() instead of str()
tabset(iris, function(x) {
if (is.factor(x)) tab_content(join_words(levels(x), before = "`"))
else print(summary(x))
})
format_bytes() converts raw byte counts to human-readable strings:
format_bytes(c(500, 2048, 1.5e6, 3e9))
#> [1] "500 bytes" "2 Kb" "1.4 Mb" "2.8 Gb"
grep_sub() combines grep(..., value = TRUE) and gsub(). It extracts matching elements and transforms them:
grep_sub('(\\w+)@(\\w+)', '\\2/\\1', c('alice@corp', 'bob@org', 'not-an-email'))
#> [1] "corp/alice" "org/bob"
is_blank() returns TRUE for empty strings and strings that contain only whitespace:
is_blank(c("", " ", "\t\n", "hello"))
#> [1] TRUE TRUE TRUE FALSE
join_words() builds a phrase from a character vector, adding a conjunction before the last word:
join_words(c("apples", "oranges", "pears"))
apples, oranges, and pears
join_words(c("R", "Python", "Julia"), and = "or")
R, Python, orJulia
join_words(c("one", "two", "three"), before = "'", after = "'")
'one', 'two', and 'three'
alnum_id() replaces non-alphanumeric characters with dashes (and strips HTML tags), which is handy for generating element IDs from arbitrary text:
alnum_id(c("Hello world 123!", "a &b*^c"))
#> [1] "hello-world-123" "a-b-c"
alnum_id("<h1>Section <em>1.2</em></h1>")
#> [1] "section-1-2"
valid_syntax() checks whether code can be parsed without running it:
valid_syntax("1 + 1")
#> [1] TRUE
valid_syntax("1 +") # incomplete
#> [1] FALSE
valid_syntax("if(T) {1 + 1") # unclosed brace
#> [1] FALSE
split_source() parses a character vector of R code line by line and groups lines into complete expressions:
code = c("# comment", "if (TRUE) {", " 1 + 1", "}", "print('done')")
split_source(code)
#> [[1]]
#> [1] "# comment"
#>
#> [[2]]
#> [1] "if (TRUE) {" " 1 + 1" "}"
#>
#> [[3]]
#> [1] "print('done')"
#>
split_source(code, merge_comments = TRUE)
#> [[1]]
#> [1] "# comment" "if (TRUE) {" " 1 + 1" "}"
#>
#> [[2]]
#> [1] "print('done')"
#>
n2w() (alias numbers_to_words()) is useful for writing reports where you want to spell out numbers:
n2w(c(0, 1, 13, 42, 100, 1001), cap = TRUE)
#> [1] "Zero" "One" "Thirteen"
#> [4] "Forty-two" "One hundred" "One thousand, one"
n2w(1e6 + 234567, and = TRUE)
#> [1] "one million, two hundred and thirty-four thousand, five hundred and sixty-seven"
n2w(-987654321)
#> [1] "minus nine hundred eighty-seven million, six hundred fifty-four thousand, three hundred twenty-one"
html_tag() constructs an HTML tag with attributes and content. Content is escaped by default; wrap it in html_value() to mark it as already-valid HTML:
html_tag("a", "R Project", href = "https://www.r-project.org", target = "_blank")
<a href="https://www.r-project.org" target="_blank">R Project</a>
html_tag("br")
<br />
html_tag("p", list("Visit ", html_tag("a", "CRAN", href = "https://cran.r-project.org")))
<p>Visit
<a href="https://cran.r-project.org">CRAN</a></p>
html_escape() replaces &, <, > (and optionally ", \r, \n for attributes):
html_escape('2 < 3 & "quotes"')
#> [1] "2 < 3 & \"quotes\""
html_escape('attr="val"', attr = TRUE)
#> [1] "attr="val""
strip_html() removes tags and comments from text:
strip_html('<p>Hello <strong>world</strong><!-- comment -->!</p>')
#> [1] "Hello world!"
read_utf8() and write_utf8() enforce UTF-8 encoding and warn when files contain invalid bytes. They are thin wrappers around readLines() / writeLines() that set the encoding correctly:
f = tempfile()
write_utf8(c("héllo", "wörld"), f)
read_utf8(f)
#> [1] "héllo" "wörld"
append_unique() adds lines to a file only if they are not already present:
f = tempfile()
write_utf8("apple", f)
append_unique(c("apple", "banana", "cherry"), f)
read_utf8(f)
#> [1] "apple" "banana" "cherry"
read_all() reads a list of files and combines their content, optionally adding a header or footer around each file’s content:
scripts = system.file("scripts", c("call-fun.R", "child-pids.sh"), package = "xfun")
read_all(scripts, before = function(f) paste("##", f), after = "")
## /private/var/folders/wx/l4gt5mk511d5s2_2xzxcrh680000gn/T/RtmpCWyaka/Rinstf4ce23d330e8/xfun/scripts/call-fun.R
# This script is executed via the command line `Rscript call-fun.R arg1 arg2
# arg3`, where arg1 is a path to an .rds file, which contains the function and
# its arguments saved as a list; arg2 is a path to an .rds file to which the
# returned value of the function call is saved; arg3 saves the error message.
local({
if (length(a <- commandArgs(TRUE)) != 3)
stop('The number of arguments passed to Rscript should be 3.')
# save the error message on exit if an error occurred
on.exit(if (!file.exists(a[2])) saveRDS(geterrmessage(), a[3]))
x = readRDS(a[1]) # list(fun, args)
f = x[[1]]
if (is.character(f)) f = eval(parse(text = f), envir = globalenv())
r = do.call(f, x[[2]], envir = globalenv())
saveRDS(r, a[2])
})
## /private/var/folders/wx/l4gt5mk511d5s2_2xzxcrh680000gn/T/RtmpCWyaka/Rinstf4ce23d330e8/xfun/scripts/child-pids.sh
# given a PID, output all its child PIDs recursively
list_children() {
for j in $(pgrep -P $1); do
echo $j
echo $(list_children $j)
done
}
for i in $@; do
list_children $i
done
gsub_file() performs an in-place gsub() on a file. All other gsub_* functions are built on top of it.
f = tempfile()
write_utf8(c("color", "favourite", "grey"), f)
gsub_file(f, "colour|grey", c(colour = "color", grey = "gray"))
#> argument 'replacement' has length > 1 and only the first element will be used
read_utf8(f)
#> [1] "color" "favourite" "color"
Warning: these functions modify files in place. Back up or use version control first.
gsub_dir() applies a substitution to every file in a directory. Narrow the scope with ext (file extensions) or mimetype (a regex on MIME types):
# change "rmarkdown" to "litedown" in all .Rmd files
gsub_dir("rmarkdown", "litedown", ext = "Rmd")
# only in text files
gsub_dir("foo", "bar", mimetype = "^text/")
process_file() reads a file, passes the lines through a function, and writes the result back:
f = tempfile()
write_utf8(c("a", "b", "c"), f)
process_file(f, rev) # reverse the order of lines
read_utf8(f)
#> [1] "c" "b" "a"
file_ext(), sans_ext(), and with_ext() manipulate the extension part of a path. They handle double extensions like .tar.gz and .nb.html that tools::file_ext() splits differently:
p = c("report.Rmd", "archive.tar.gz", "notes.nb.html", "script.R")
file_ext(p)
#> [1] "Rmd" "tar.gz" "nb.html" "R"
sans_ext(p)
#> [1] "report" "archive" "notes" "script"
with_ext(p, ".html")
#> [1] "report.html" "archive.html" "notes.html" "script.html"
with_ext(p, c(".pdf", ".zip", ".html", ".txt"))
#> [1] "report.pdf" "archive.zip" "notes.html" "script.txt"
normalize_path() is a wrapper around normalizePath() that uses / on all platforms and does not signal an error for non-existent paths by default. same_path() compares two paths after normalizing both:
normalize_path("~")
#> [1] "/Users/yihui"
same_path("~/foo", file.path(Sys.getenv("HOME"), "foo"))
#> [1] TRUE
from_root() returns a path relative to the project root (detected by proj_root()), which is useful in documents that live in a subdirectory:
# in docs/report.Rmd, this returns ../data/cars.csv
from_root("data", "cars.csv")
magic_path() finds a file by basename anywhere under the project, so you can move files without breaking references:
# finds data/raw/survey.csv wherever it lives
magic_path("survey.csv")
base64_encode() and base64_decode() convert between raw bytes and base64 strings. base64_uri() produces a data URI that embeds a file directly in HTML — the mechanism behind xfun::embed_file():
raw_bytes = charToRaw("Hello, xfun!")
(b64 = base64_encode(raw_bytes))
#> [1] "SGVsbG8sIHhmdW4h"
rawToChar(base64_decode(b64))
#> [1] "Hello, xfun!"
# data URI for a small file
f = system.file("LICENSE", package = "xfun")
substr(base64_uri(f), 1, 60)
#> [1] "data:text/plain;base64,WUVBUjogMjAxOC0yMDI2CkNPUFlSSUdIVCBIT"
tojson() converts R objects to JSON (or JavaScript). It handles NULL/NA, named lists as objects, unnamed lists as arrays, matrices by row, and data frames by column:
tojson(list(x = 1:3, y = c(TRUE, FALSE), z = NULL))
{
"x": [1, 2, 3],
"y": [true, false],
"z": null
}
tojson(head(iris, 3)) # named data frame → columns
{
"Sepal.Length": [5.1, 4.9, 4.7],
"Sepal.Width": [3.5, 3, 3.2],
"Petal.Length": [1.4, 1.4, 1.3],
"Petal.Width": [0.2, 0.2, 0.2],
"Species": ["setosa", "setosa", "setosa"]
}
tojson(matrix(1:6, 2)) # matrix → rows
[
[1, 3, 5],
[2, 4, 6]
]
Wrap a string in js() to embed it as raw JavaScript without quoting:
tojson(list(n = 42, callback = js("function(x) { return x * 2; }")))
{
"n": 42,
"callback": function(x) { return x * 2; }
}
For pure static JSON (valid for JSON.parse()), use mode = "json". Dates become strings, Inf becomes null:
tojson(list(d = Sys.Date(), x = c(1, Inf, -Inf)), mode = "json")
{
"d": "2026-10-15",
"x": [1, null, null]
}
When many values repeat, dict dictionary-encodes a vector as an index + unique-values pair, which can be substantially shorter:
x = rep(c("apple", "banana", "cherry"), 100)
nchar(tojson(x, pretty = FALSE)) # without dict encoding
#> [1] 2601
nchar(tojson(x, dict = 1, pretty = FALSE)) # with dict encoding
#> [1] 642
cache_exec() caches the result of an expression on disk. It automatically detects which global variables the expression uses and invalidates the cache when any of them change. Re-running the same expression loads the cache instead of recomputing:
path = tempfile()
x = 42
result = cache_exec(
{ Sys.sleep(0); x^2 },
path = path, id = "square"
)
result # 1764
#> [1] 1764
# change x — cache is invalidated
x = 10
result = cache_exec(
{ Sys.sleep(0); x^2 },
path = path, id = "square"
)
result # 100
#> [1] 100
Pass extra = file.mtime("data.csv") to also invalidate when an external file changes.
record() runs a character vector of R code and captures all output types — text, plots, messages, warnings, and errors — into a structured list:
code = c(
"1 + 1",
"message('hello from record()')",
"warning('a warning')",
"head(mtcars, 2)"
)
res = record(code, dev.path = FALSE, error = TRUE)
tree(res)
List of 8
|-: 'record_source' chr "1 + 1"
| - attr(*, "lines")= int [1:2] 1 1
|-: 'record_output' chr "[1] 2"
|-: 'record_source' chr "message('hello from record()')"
| - attr(*, "lines")= int [1:2] 2 2
|-: 'record_message' chr "hello from record()\n"
|-: 'record_source' chr "warning('a warning')"
| - attr(*, "lines")= int [1:2] 3 3
|-: 'record_warning' chr "a warning"
|-: 'record_source' chr "head(mtcars, 2)"
| - attr(*, "lines")= int [1:2] 4 4
|-: 'record_asis' chr [1:5] "| |mpg|cyl|disp|hp|drat|wt|qsec|vs|am|gear|carb|" "|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|" "|Mazda RX4|21|6|160|110|3.9|2.620|16.46|0|1|4|4|" "|Mazda RX4 Wag|21|6|160|110|3.9|2.875|17.02|0|1|4|4|" ...
- attr(*, "class")= chr "xfun_record_results"
Each element carries a class like record_source, record_output, record_message, or record_warning. This is the engine behind litedown’s code chunk execution.
pkg_attach() is a vectorized library(), and pkg_attach2() installs missing packages first:
pkg_attach("stats", "utils") # equivalent to two library() calls
pkg_attach2("ggplot2", "dplyr") # installs any that are missing, then attaches
loadable() is a concise alternative to requireNamespace(quietly = TRUE):
loadable("stats")
loadable("nonexistent.package.xyz")
env_option() checks options() first, then looks for an environment variable named R_<NAME> (uppercased, dots to underscores). This lets package authors support both .Rprofile and .Renviron configuration:
Sys.setenv(R_XFUN_TEST_OPTION = "from-env")
env_option("xfun.test.option") # "from-env"
#> [1] "from-env"
options(xfun.test.option = TRUE)
env_option("xfun.test.option") # TRUE (options() wins)
#> [1] TRUE
options(xfun.test.option = NULL) # reset
env_option("xfun.test.option") # "from-env" again
#> [1] "from-env"
Sys.unsetenv("R_XFUN_TEST_OPTION")
set_envvar() sets variables and returns their previous values so you can restore them:
old = set_envvar(c(MY_TOKEN = "secret"))
Sys.getenv("MY_TOKEN")
#> [1] "secret"
set_envvar(old) # restore
Sys.getenv("MY_TOKEN")
#> [1] ""
in_dir() temporarily changes the working directory, then restores it:
in_dir(tempdir(), {
writeLines("hello", "tmp.txt")
file.exists("tmp.txt")
})
#> [1] TRUE
file.exists(file.path(tempdir(), "tmp.txt"))
#> [1] TRUE
The is_*() family detects the current operating system:
is_windows()
#> [1] FALSE
is_macos()
#> [1] TRUE
is_linux()
#> [1] FALSE
is_unix() # TRUE on macOS and Linux
#> [1] TRUE
is_arm64() # TRUE on Apple silicon
#> [1] TRUE
try_error() returns TRUE/FALSE without stopping execution — cleaner than wrapping code in tryCatch():
try_error(log(1))
#> [1] FALSE
try_error(log("a"))
#> [1] TRUE
try_silent() behaves like try(silent = TRUE):
z = try_silent(sqrt("x"))
inherits(z, "try-error")
#> [1] TRUE
retry() calls a function repeatedly until it succeeds or the attempt limit is reached, pausing between tries. Useful for flaky network operations:
# retry a download up to 3 times, waiting 5 seconds between attempts
retry(download.file, url = "https://example.com/data.csv", destfile = "data.csv")
rev_check() automates running R CMD check on the reverse dependencies of a package. The main pain in package development is discovering whether a new version breaks downstream packages; this function handles downloading, parallel checking, and diffing results against the current CRAN version so new failures stand out:
# check all reverse dependencies of knitr, using 4 cores
rev_check("knitr", src = "path/to/knitr_source", n = 4)
After the run, compare_Rcheck() produces a Markdown diff showing only the packages whose check status changed, so you can focus on regressions rather than pre-existing failures.
rstudio_type() feeds characters into the RStudio source editor as if typed by a human — useful for live demos and screencasts where you want code to appear gradually rather than pasting it all at once:
rstudio_type("1 + 1\n", pause = function() 0.05)
The mistake argument introduces random typos (which are immediately corrected) for a more realistic typing effect.
session_info() trims sessionInfo() output — no base-package noise, no matrix/BLAS details — and accepts a package list for a focused summary:
session_info(c("xfun", "litedown"), dependencies = FALSE)
R version 4.6.1 (2026-06-24)
Platform: aarch64-apple-darwin23
Running under: macOS Golden Gate 27.0.1
Locale: C / en_US.UTF-8 / en_US.UTF-8 / C / en_US.UTF-8 / en_US.UTF-8
Package version:
litedown_0.11.7 xfun_0.62
do_once() executes a task the first time it is called and skips it on subsequent calls in the same R session. Useful for showing a deprecation notice or one-time setup:
for (i in 1:3) {
do_once(message("This prints once."), "my-one-time-task")
}