-
Notifications
You must be signed in to change notification settings - Fork 0
/
convert.R
285 lines (248 loc) · 9.06 KB
/
convert.R
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
#' Convert `roxygen2` tags to `roxytypes` tags
#'
#' Convert a package codebase into applicable `roxytypes` tags. For `roxygen2`
#' tags with drop-in replacements (namely `@param` and `@return` tags), process
#' descriptions and replace tags with `roxytypes` equivalents.
#'
#' @details
#' A format string is built using [build_format_regex()], which accepts
#' parameters `type` and `description`, which describe how to match these
#' components of a parameter definition. They are combined with the literal
#' content of `format` to produce a regular expression to split existing
#' definitions.
#'
#' For comprehensive control, pass `format_re` directly, bypassing expression
#' construction altogether.
#'
#' @typed format: character[1]
#' A `glue`-style format to use to parse types and descriptions for conversion
#' to `roxytypes` tags. Available `glue` keywords include `type` and
#' `description`. By default, `type` will match any string until a closing
#' backtick and `description` will match any string. See details for more
#' information.
#' @param ... Additional arguments passed to [build_format_regex()].
#' @typed unmatched: logical[1]
#' Indicates whether tags that fail to match should still be converted into
#' `roxytypes` tags. Such conversions may be convenient if you aim to convert
#' your package holistically, as it will help to flag undocumented parameter
#' types the next time you re-build your documentation.
#' @typed path: character[1]
#' A file path within your package. Defaults to the current working directory.
#' @typed verbose: logical[1]
#' Indicates whether command-line interface should be emitted so that changes
#' can be reviewed interactively.
#'
#' @typedreturn logical[1]
#' `TRUE` if successfully completes, `FALSE` if aborted. Always returns
#' invisibly.
#'
#' @examples
#' \dontrun{
#' convert("(`{type}`) {description}")
#' }
#'
#' @export
convert <- function(
path = ".",
format = config(path, refresh = TRUE, cache = FALSE)$format,
...,
unmatched = FALSE,
verbose = interactive()) {
# process format to build expression for matching tag decriptions
format <- build_format_regex(format, ...)
# build index of all package roxygen tags
blocks <- roxygen_blocks(path = path, refresh = TRUE, cache = FALSE)
tags <- unlist(lapply(blocks, `[[`, "tags"), recursive = FALSE)
# filter ellipsis, they are not converted
tag_is_ellipsis <- function(t) is.list(t$val) && identical(t$val$name, "...")
is_ellipsis <- vlapply(tags, tag_is_ellipsis)
# build modification index
tags <- tags[!is_ellipsis]
edits <- build_convert_edits(format, tags, unmatched = unmatched)
continue <- 3
if (nrow(edits) > 0 && verbose) {
repeat {
preview_convert_edits(edits, n = continue)
continue <- convert_continue_prompt()
if (isTRUE(continue)) break # continue with edits
if (!is.numeric(continue)) {
return(invisible(FALSE))
} # abort
}
}
n_edits <- make_convert_edits(edits)
if (verbose && n_edits > 0) {
cli::cli_alert_success("{.val {n_edits}} tags converted")
}
file_edits <- make_config_edits(path)
if (verbose && length(file_edits) > 0) {
cli::cli_alert_success("{.file {file_edits}} updated")
}
invisible(TRUE)
}
#' Convert a `roxygen2` tag to `roxytypes` equivalent
#'
#' @typed tag: [roxygen2::roxy_tag()]
#' A `roxygen2` tag to convert.
#' @inheritParams convert_match_format
#' @param ... Additional arguments unused.
#'
#' @typedreturn `NULL` or [tag_edit()]
#' If the tag can be converted, a [tag_edit()] is returned, otherwise `NULL`.
#'
#' @family convert
#' @keywords internal
convert_tag <- function(tag, format, ...) {
UseMethod("convert_tag", structure(list(), class = tag$tag))
}
#' @describeIn convert_tag
#' Default handler for tags that can not be converted.
#'
convert_tag.default <- function(tag, format, ...) {
NULL
}
#' @describeIn convert_tag
#' Convert `@return` tags, parsing type and description from existing
#' description.
#'
convert_tag.return <- function(tag, format, ...) {
m <- convert_match_format(tag$val, format)
desc <- paste0(" ", split_and_trim(m$description), collapse = "\n")
new <- sprintf("@typedreturn %s\n%s", m$type, desc)
new <- paste0("#' ", trimws(strsplit(new, "\n")[[1]], which = "right"))
tag_edit(tag, new, m$matched)
}
#' @describeIn convert_tag
#' Convert `@param` tags, parsing type and description from existing
#' description.
#'
convert_tag.param <- function(tag, format, ...) {
m <- convert_match_format(tag$val$description, format)
new_desc <- paste0(" ", split_and_trim(m$description), collapse = "\n")
new <- sprintf("@typed %s: %s\n%s", tag$val$name, m$type, new_desc)
new <- paste0("#' ", trimws(strsplit(new, "\n")[[1]], which = "right"))
tag_edit(tag, new, m$matched)
}
#' Match a conversion format and structure results
#'
#' @typed x: character[1]
#' Content to match.
#' @typed format: character[1]
#' A regular expression, optionally containing named capture groups for `type`
#' and `description`, which will be used for restructuring the tag as a
#' `roxytypes`-equivalent tag.
#'
#' @typedreturn: list
#' A named list of `type`, `description` and `matched` fields. `type` and
#' `description` represent the result of captured groups. If no capture groups
#' were used, the raw string is used as a description. `matched` is a
#' `logical[1]` indicating whether the provided format matched against the
#' provided input.
#'
#' @family convert
#' @keywords internal
convert_match_format <- function(x, format) {
res <- list(type = "", description = x, matched = FALSE)
matches <- regex_capture(format, x, perl = TRUE)
res$matched <- !all(nchar(matches) == 0)
if (res$matched) {
cols <- colnames(matches)
if ("type" %in% cols) res$type <- matches[, "type"]
if ("description" %in% cols) res$description <- matches[, "description"]
}
res
}
#' Built a conversion edit
#'
#' @inheritParams convert_tag
#' @typed new: character
#' The new content used to replace the tag.
#' @typed matched: logical[1]
#' Whether the content was generated based on a match of a specified format.
#'
#' @typedreturn data.frame
#' A single-observation dataset representing information for a tag edit. The
#' `data.frame` row includes variables:
#'
#' - `file`: (`character[1]`) The source file for the tag.
#' - `line`: (`integer[1]`) The first line of the tag.
#' - `n`: (`integer[1]`) The number of lines the tag spans.
#' - `matched`: (`logical[1]`) Whether the tag matched a specified format.
#' - `new`: (`list[1](character)`) The new contents to replace the tag.
#'
#' @family convert
#' @keywords internal
tag_edit <- function(tag, new, matched) {
edit <- data.frame(
file = tag$file,
line = tag$line,
n = length(strsplit(tag$raw, "\n")[[1]]),
matched = matched
)
edit$new <- list(new)
edit
}
#' Build a collection of conversion edits
#'
#' @inheritParams convert_match_format
#' @typed tags: list(roxy_tag)
#' A collection of [roxygen2::roxy_tag()] objects to edit. Can include tags
#' which have no plausible conversion, which will be filtered before returning
#' edits.
#' @typed unmatched: logical[1]
#' Whether to make edits to existing tags which can not be matched with the
#' provided format. If `TRUE`, the existing description is migrated verbatim
#' to the new tag, without a type provided. The new syntax will produce
#' warnings when running [roxygen2::roxygenize()], which can be useful tool
#' for pinpointing tags that need manual fine-tuning for conversion.
#'
#' @typedreturn data.frame
#' A collection of possible tag edits as produced by [tag_edit()].
#'
#' @family convert
#' @keywords internal
build_convert_edits <- function(format, tags, unmatched = FALSE) {
edits <- lapply(tags, function(tag, f) convert_tag(tag, f), format)
edits <- do.call(rbind, Filter(Negate(is.null), edits))
# if absolutely no edits to be made, return an empty edit frame
if (is.null(edits)) {
spoof_tag <- roxygen2::roxy_tag(raw = "", tag = "")
return(tag_edit(spoof_tag, new = "", matched = FALSE)[c(), ])
}
if (!unmatched) {
edits <- edits[edits[["matched"]], ]
}
edits[order(edits$file, edits$line), ]
}
#' Make tag edits
#'
#' @typed edits: data.frame
#' A collection of edits (one edit per row), as produced by
#' [tag_edit()].
#'
#' @typedreturn integer[1]
#' The number of edits that were made.
#'
#' @family convert
#' @keywords internal
make_convert_edits <- function(edits) {
edits_by_file <- split(edits, edits$file)
for (i in seq_along(edits_by_file)) {
file <- names(edits_by_file[i])
file_edits <- edits_by_file[[i]]
text <- readLines(file)
# work through edits from the end of the file backwards so that we don't
# need to worry about line offsets affecting future edits.
for (edit_i in rev(seq_len(nrow(file_edits)))) {
edit <- file_edits[edit_i, ]
after <- edit$line - 1
lines <- after + 1:edit$n
# remove old lines
text <- text[-lines]
# add edited lines
text <- append(text, edit[[1, "new"]], after = after)
}
writeLines(text, file)
}
nrow(edits)
}