Skip to content

Commit

Permalink
enhance tojson()
Browse files Browse the repository at this point in the history
  • Loading branch information
yihui committed Jan 10, 2025
1 parent f195d0e commit bdd2c1e
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 50 deletions.
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: xfun
Type: Package
Title: Supporting Functions for Packages Maintained by 'Yihui Xie'
Version: 0.50.1
Version: 0.50.2
Authors@R: c(
person("Yihui", "Xie", role = c("aut", "cre", "cph"), email = "[email protected]", comment = c(ORCID = "0000-0003-0645-5666", URL = "https://yihui.org")),
person("Wush", "Wu", role = "ctb"),
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# CHANGES IN xfun VERSION 0.51

- `tojson()` supports more types of data now, and will indent sub-elements for lists. See the help page [`?xfun::tojson`](https://git.yihui.org/xfun/manual.html#sec:man-tojson) for details.

# CHANGES IN xfun VERSION 0.50

- The function `isFALSE()` has been removed from this package. The deprecation notice was given two years ago: https://yihui.org/en/2023/02/xfun-isfalse/
Expand Down
84 changes: 58 additions & 26 deletions R/json.R
Original file line number Diff line number Diff line change
@@ -1,49 +1,80 @@
#' A simple JSON serializer
#'
#' A JSON serializer that only works on a limited types of R data (`NULL`,
#' lists, logical scalars, character/numeric vectors). A character string of the
#' class `JS_EVAL` is treated as raw JavaScript, so will not be quoted. The
#' function `json_vector()` converts an atomic R vector to JSON.
#' lists, arrays, logical/character/numeric vectors). Other types of data will
#' be coerced to character. A character string of the class `JS_LITERAL` is
#' treated as raw JavaScript, so will not be quoted. The function
#' `json_vector()` converts an atomic R vector to JSON.
#'
#' Both `NULL` and `NA` are converted to `null`. Named lists are converted to
#' objects of the form `{key1: value1, key2: value2, ...}`. Unnamed lists are
#' converted to arrays of the form `[[value1], [value2], ...]`. The same rules
#' apply to data frames since technically they are also lists. However, please
#' note that unnamed data frames (i.e., without column names) will be converted
#' to an array with each _row_ as an array element, whereas named data frames
#' will have each _column_ as an individual element. For matrices, the JSON
#' array will have each row as an individual element, and names are discarded.
#' @param x An R object.
#' @export
#' @return A character string.
#' @seealso The \pkg{jsonlite} package provides a full JSON serializer.
#' @examples library(xfun)
#' tojson(NULL); tojson(1:10); tojson(TRUE); tojson(FALSE)
#' cat(tojson(list(a = 1, b = list(c = 1:3, d = 'abc'))))
#' cat(tojson(list(c('a', 'b'), 1:5, TRUE)))
#' tojson(list(a = 1, b = list(c = 1:3, d = 'abc')))
#' tojson(list(c('a', 'b'), 1:5, TRUE))
#' tojson(head(iris)) # each column is in an element
#' tojson(unname(head(iris))) # each row is in an element
#' tojson(matrix(1:12, 3))
#'
#' # the class JS_EVAL is originally from htmlwidgets::JS()
#' JS = function(x) structure(x, class = 'JS_EVAL')
#' cat(tojson(list(a = 1:5, b = JS('function() {return true;}'))))
tojson = function(x) {
if (is.null(x)) return('null')
if (is.logical(x)) {
if (length(x) != 1 || any(is.na(x)))
stop('Logical values of length > 1 and NA are not supported')
return(tolower(as.character(x)))
}
if (is.character(x) && inherits(x, 'JS_EVAL')) return(paste(x, collapse = '\n'))
if (is.character(x) || is.numeric(x)) {
return(json_vector(x, length(x) != 1 || inherits(x, 'AsIs'), is.character(x)))
#' # literal JS code
#' JS = function(x) structure(x, class = 'JS_LITERAL')
#' tojson(list(a = 1:5, b = JS('function() {return true;}')))
tojson = function(x) raw_string(.tojson(x), lang = '.json')

.tojson = function(x, n = 1) {
make_array = function(..., braces = c('[', ']')) {
inner = paste0(strrep(' ', n), ..., collapse = ',\n')
paste0(braces[1], '\n', inner, '\n', strrep(' ', n - 1), braces[2])
}
if (is.list(x)) {
if (is.null(x)) 'null' else if (is.array(x)) {
make_array(apply(x, 1, .tojson, n + 1))
} else if (is.list(x)) {
if (length(x) == 0) return('{}')
return(if (is.null(names(x))) {
json_vector(unlist(lapply(x, tojson)), TRUE, quote = FALSE)
# output unnamed data frames by rows instead of columns
nms = names(x)
by_row = is.data.frame(x) && is.null(nms)
cols = unlist(lapply(x, function(z) {
if (by_row) json_atomic(z, FALSE) else .tojson(z, n + 1)
}))
if (is.null(nms)) {
if (by_row) {
dim(cols) = dim(x)
cols = apply(cols, 1, json_vector, TRUE, FALSE)
}
make_array(cols)
} else {
nms = quote_string(names(x))
paste0('{\n', paste(nms, unlist(lapply(x, tojson)), sep = ': ', collapse = ',\n'), '\n}')
})
}
stop('The class of x is not supported: ', paste(class(x), collapse = ', '))
make_array(quote_string(nms), ': ', cols, braces = c('{', '}'))
}
} else if (is.character(x) && inherits(x, c('JS_LITERAL', 'JS_EVAL'))) {
paste(x, collapse = '\n')
} else json_atomic(x)
}

json_atomic = function(x, to_array = NA) {
use_quote = !(is.numeric(x) || is.logical(x))
asis = inherits(x, 'AsIs')
if (is.factor(x)) x = as.character(x)
if (is.logical(x)) x = tolower(as.character(x))
if (is.na(to_array)) to_array = length(x) != 1 || asis
json_vector(x, to_array, use_quote)
}

#' @param to_array Whether to convert a vector to a JSON array (use `[]`).
#' @param quote Whether to double quote the elements.
#' @rdname tojson
#' @export
json_vector = function(x, to_array = FALSE, quote = TRUE) {
i = is.na(x)
if (quote) {
x = quote_string(x)
x = gsub('\n', '\\\\n', x)
Expand All @@ -52,6 +83,7 @@ json_vector = function(x, to_array = FALSE, quote = TRUE) {
x = gsub('\r', '\\\\r', x)
x = gsub('\t', '\\\\t', x)
}
x[i] = 'null'
if (to_array) paste0('[', paste(x, collapse = ', '), ']') else x
}

Expand Down
30 changes: 22 additions & 8 deletions man/tojson.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 14 additions & 15 deletions tests/test-cran/test-json.R
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
library(testit)

assert("tojson() works", {
(tojson(NULL) %==% "null")
(tojson(list()) %==% "{}")
(has_error(tojson(Sys.Date())))
(has_error(tojson(NA)))
(tojson(NA_character_) %==% '"NA"')
(tojson(1:10) %==% "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]")
(tojson(TRUE) %==% "true")
(tojson(FALSE) %==% "false")
(.tojson(NULL) %==% "null")
(.tojson(list()) %==% "{}")
(.tojson(NA) %==% 'null')
(.tojson(NA_character_) %==% 'null')
(.tojson(1:10) %==% "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]")
(.tojson(TRUE) %==% "true")
(.tojson(FALSE) %==% "false")

x = list(a = 1, b = list(c = 1:3, d = "abc"))
out = '{\n"a": 1,\n"b": {\n"c": [1, 2, 3],\n"d": "abc"\n}\n}'
(tojson(x) %==% out)
out = '{\n "a": 1,\n "b": {\n "c": [1, 2, 3],\n "d": "abc"\n }\n}'
(.tojson(x) %==% out)

x = list(c("a", "b"), 1:5, TRUE)
out = '[["a", "b"], [1, 2, 3, 4, 5], true]'
(tojson(x) %==% out)
out = '[\n ["a", "b"],\n [1, 2, 3, 4, 5],\n true\n]'
(.tojson(x) %==% out)

(tojson(list('"a b"' = 'quotes "\'')) %==% '{\n"\\"a b\\"": "quotes \\"\'"\n}')
(.tojson(list('"a b"' = 'quotes "\'')) %==% '{\n "\\"a b\\"": "quotes \\"\'"\n}')

JS = function(x) structure(x, class = "JS_EVAL")
x = list(a = 1:5, b = JS("function() {return true;}"))
out = '{\n"a": [1, 2, 3, 4, 5],\n"b": function() {return true;}\n}'
(tojson(x) %==% out)
out = '{\n "a": [1, 2, 3, 4, 5],\n "b": function() {return true;}\n}'
(.tojson(x) %==% out)
})

0 comments on commit bdd2c1e

Please sign in to comment.