diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..a0d151f --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,46 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: R-CMD-check + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: ubuntu-latest, r: 'release'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + args: 'c("--no-manual", "--as-cran", "--no-vignettes")' + build_args: 'c("--no-manual", "--no-build-vignettes")' + error-on: '"error"' diff --git a/DESCRIPTION b/DESCRIPTION index ea53792..d068def 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -28,5 +28,5 @@ Suggests: Encoding: UTF-8 Language: en-US LazyData: true -RoxygenNote: 7.1.0 +RoxygenNote: 7.3.3 VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index 31fd00f..b7c458e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,12 +2,19 @@ export(align_arrow) export(align_equal) +export(align_pipe) +export(align_pipe_text) +export(align_text) +export(align_tilde) export(backtickr) export(blockquoter) export(boldr) +export(br_r) export(chunknamer) export(chunkr) export(chunksplitr) +export(cite_packages) +export(default_hotkeys) export(entire_document) export(footnoter) export(h1r) @@ -16,6 +23,7 @@ export(h3r) export(h4r) export(h5r) export(h6r) +export(headr_toggler) export(htmlcommentr) export(id_ref) export(imager) @@ -25,14 +33,17 @@ export(italicsr) export(latexr) export(listr) export(olistr) +export(packages_in_text) export(remedy_example) export(remedy_opts) export(remedy_opts_current) +export(rename_chunks) export(rightr) export(scratch_file) export(set_text) export(striker) export(tabler) +export(toggle_header) export(urlr) export(xaringanr) export(youtuber) diff --git a/NEWS.md b/NEWS.md index 838ef1c..1cacaf3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,24 @@ # remedy 0.1.0.9000 +* `chunknamer()` no longer mangles non-R engine chunks such as `stan`, `python`, + `sql` or `bash` (#57). Text transformation is now exposed as the pure helper + `rename_chunks()` for easier testing. +* `chunknamer()` / `rename_chunks()` accept `remedy_opts$set(name = "")` to + disable auto-naming (#73). +* New `align_tilde()` addin aligns `~` in a highlighted region (#67). +* New `align_pipe()` addin aligns `|` columns in a markdown table (#58), + backed by the pure helper `align_pipe_text()`. +* New helper `align_text()` factored out from the `align_*` addins. +* New `default_hotkeys()` helper builds the default hotkey map; on + Windows / Linux `Cmd` is rewritten to `Ctrl` so the defaults are usable + cross-platform (#70). Shortcuts for `align_arrow`, `align_equal`, + `align_tilde`, `align_pipe` and `br` are included by default (#29, #60). +* New `br_r()` addin inserts a `
` line break at the cursor (#60). +* New `headr_toggler()` addin and `toggle_header()` helper cycle the header + level of the current line (#59). +* New `cite_packages()` helper returns a bulleted markdown list of the + citations for packages referenced in the current document (#20). The + discovery helper `packages_in_text()` is also exported. * Clean pkgdown on github * Added travis support and codecov. diff --git a/R/align.R b/R/align.R index 23d20af..5eb5dbb 100644 --- a/R/align.R +++ b/R/align.R @@ -1,39 +1,32 @@ -## Currently taken verbatim from -## https://github.com/seasmith/AlignAssign/blob/b32a2f0847a7818c9768a105cf2d891db0b0ee8d/R/align_assign.R -## as of 11-Nov-2016 -## since AlignAssign is not on CRAN and CRAN packages can't have Remotes dependencies -## J. Carroll: I have updated the roxygen tags but otherwise this is as-per that commit +## Core alignment primitives were originally taken from +## https://github.com/seasmith/AlignAssign (see also #67, #58) capture <- function() { - # Get context rstudioapi::getActiveDocumentContext() } capture_area <- function(capture) { - # Find range range_start <- capture$selection[[1L]]$range$start[[1L]] range_end <- capture$selection[[1L]]$range$end[[1L]] - - # Dump contents and use highlighted lines as names. + contents <- capture$contents[range_start:range_end] names(contents) <- range_start:range_end return(contents) } find_regex <- function(find, where) { - - # Find matches, extract positions, find furthest <-, get rows/cols to align. + matched.rows <- grep(find, where) positions <- regexec(find, where) positions <- positions[matched.rows] - + lines.highlighted <- as.integer(names(where)) matched.cols <- sapply(positions, `[[`, 1L) which.max.col <- which.max(matched.cols) - + furthest_row <- lines.highlighted[matched.rows[which.max.col]] furthest_column <- max(matched.cols) - + return(list(matched.rows = matched.rows, matched.cols = matched.cols, lines.highlighted = lines.highlighted, @@ -42,26 +35,22 @@ find_regex <- function(find, where) { } assemble_insert <-function(info) { - # Unload variables matched.rows <- info$matched.rows matched.cols <- info$matched.cols lines.highlighted <- info$lines.highlighted which.max.col <- info$which.max.col furthest_column <- info$furthest_column - - # Find the rows to align and the current column position of each regEx match. + rows_to_align <- lines.highlighted[matched.rows[-which.max.col]] columns_to_align <- matched.cols[-which.max.col] - - # Set location for spaces to be inserted. + location <- Map(c, rows_to_align, columns_to_align) - - # Find and set the number of spaces to insert on each line. + text_num <- furthest_column - columns_to_align text <- vapply(text_num, function(x) paste0(rep(" ", x), collapse = ""), character(1)) - + return(list(location = location, text = text)) } @@ -69,26 +58,110 @@ insertr <- function(list) { rstudioapi::insertText(list[["location"]], list[["text"]]) } +#' Pad a character vector so the first occurrence of `find` lines up +#' +#' Pure helper shared by the `align_*` addins. Adds spaces before the +#' first match of `find` on each line so the matches sit at the same column. +#' Lines without a match are left untouched. +#' +#' @param lines character vector of lines. +#' @param find character(1), a regular expression (unless `fixed = TRUE`). +#' @param fixed logical, passed to `regexpr`. +#' +#' @return a character vector the same length as `lines`. +#' @keywords internal +#' @export +align_text <- function(lines, find, fixed = TRUE) { + positions <- as.integer(regexpr(find, lines, fixed = fixed)) + hits <- positions > 0 + if (sum(hits) < 2L) return(lines) + target <- max(positions[hits]) + to_pad <- which(hits & positions < target) + for (i in to_pad) { + n <- target - positions[i] + lines[i] <- paste0( + substr(lines[i], 1L, positions[i] - 1L), + strrep(" ", n), + substr(lines[i], positions[i], nchar(lines[i])) + ) + } + lines +} + +#' Align pipe (`|`) columns in a markdown table +#' +#' Pure helper used by [align_pipe()]. Each line that contains a `|` is +#' treated as a table row; cells are right-padded so the `|` separators +#' line up across rows. Non-pipe lines pass through untouched. +#' +#' @param lines character vector of lines. +#' +#' @return a character vector the same length as `lines`. +#' @keywords internal +#' @export +align_pipe_text <- function(lines) { + has_pipe <- grepl("|", lines, fixed = TRUE) + if (!any(has_pipe)) return(lines) + + idx <- which(has_pipe) + cells <- lapply(lines[idx], function(line) { + parts <- strsplit(line, "|", fixed = TRUE)[[1L]] + # strsplit drops trailing empty fields; preserve the closing `|`. + if (endsWith(line, "|")) parts <- c(parts, "") + parts + }) + n_cols <- max(lengths(cells)) + cells <- lapply(cells, function(r) { + if (length(r) < n_cols) c(r, rep("", n_cols - length(r))) else r + }) + + widths <- do.call(pmax, lapply(cells, nchar)) + + for (i in seq_along(cells)) { + row <- cells[[i]] + padded <- mapply(function(cell, w) { + if (nchar(cell) < w) paste0(cell, strrep(" ", w - nchar(cell))) else cell + }, row, widths, SIMPLIFY = TRUE, USE.NAMES = FALSE) + lines[idx[i]] <- paste(padded, collapse = "|") + } + lines +} + +## Internal: rewrite the highlighted region in-place via align_text(). +align_addin <- function(find, fixed = TRUE) { + adc <- rstudioapi::getActiveDocumentContext() + sel <- adc$selection[[1L]]$range + start_row <- sel$start[[1L]] + end_row <- sel$end[[1L]] + + lines <- adc$contents[start_row:end_row] + aligned <- align_text(lines, find = find, fixed = fixed) + + rng <- rstudioapi::document_range( + start = rstudioapi::document_position(start_row, 1L), + end = rstudioapi::document_position(end_row, + nchar(lines[length(lines)]) + 1L) + ) + rstudioapi::modifyRange(rng, paste(aligned, collapse = "\n"), id = adc$id) + invisible(NULL) +} + #' Align a highlighted region's assignment operators. #' #' @return Aligns the single assignment operators (\code{<-}) within a highlighted region. #' @export -#' -#' @examples +#' +#' @examples #' \dontrun{ #' remedy_example( #' c( "# Align arrows", -#' "a <- 12", -#' "aaa <- 13"), +#' "a <- 12", +#' "aaa <- 13"), #' align_arrow #' ) #' } align_arrow <- function() { - capture <- capture() - area <- capture_area(capture) - loc <- find_regex("<-", area) - insertList <- assemble_insert(loc) - insertr(insertList) + align_addin("<-") } #' Align a highlighted region's assignment operators. @@ -96,19 +169,66 @@ align_arrow <- function() { #' @return Aligns the equal sign assignment operators (\code{=}) within a #' highlighted region. #' @export -#' @examples +#' @examples #' \dontrun{ #' remedy_example( #' c( "# Align equal signs", -#' "a = 12", -#' "aaa = 13"), +#' "a = 12", +#' "aaa = 13"), #' align_equal #' ) #' } align_equal <- function() { - capture <- capture() - area <- capture_area(capture) - loc <- find_regex("=", area) - insertList <- assemble_insert(loc) - insertr(insertList) -} \ No newline at end of file + align_addin("=") +} + +#' Align a highlighted region's tildes (`~`) +#' +#' Useful e.g. for the right-hand side of `case_when()` branches. +#' +#' @return Aligns the first `~` on each highlighted line. +#' @export +#' @examples +#' \dontrun{ +#' remedy_example( +#' c( "# Align tildes", +#' "a ~ 12", +#' "aaa ~ 13"), +#' align_tilde +#' ) +#' } +align_tilde <- function() { + align_addin("~") +} + +#' Align `|` columns in a markdown table +#' +#' Pads cells in a highlighted markdown table so the `|` separators line up. +#' +#' @return Modifies the selected region in-place in the RStudio editor. +#' @export +#' @examples +#' \dontrun{ +#' remedy_example( +#' c( +#' "|a|bb|ccc|", +#' "|1|2|3|" +#' ), +#' align_pipe +#' ) +#' } +align_pipe <- function() { + adc <- rstudioapi::getActiveDocumentContext() + sel <- adc$selection[[1L]]$range + start_row <- sel$start[[1L]] + end_row <- sel$end[[1L]] + + lines <- adc$contents[start_row:end_row] + aligned <- align_pipe_text(lines) + + rng <- rstudioapi::document_range( + start = rstudioapi::document_position(start_row, 1L), + end = rstudioapi::document_position(end_row, nchar(lines[length(lines)]) + 1L) + ) + rstudioapi::modifyRange(rng, paste(aligned, collapse = "\n"), id = adc$id) +} diff --git a/R/br.R b/R/br.R new file mode 100644 index 0000000..b09276d --- /dev/null +++ b/R/br.R @@ -0,0 +1,16 @@ +#' Insert a HTML line break +#' +#' RStudio addin that inserts an HTML `
` line break at the cursor +#' (issue #60). Handy when you want a visible blank line between paragraphs +#' in knitted output. +#' +#' @return Invisible NULL. Used for its side effect on the active document. +#' @export +br_r <- function() { + adc <- rstudioapi::getActiveDocumentContext() + rstudioapi::insertText( + location = adc$selection[[1L]]$range, + text = "
" + ) + invisible(NULL) +} diff --git a/R/chunknamer.R b/R/chunknamer.R index bada1a6..8a79634 100644 --- a/R/chunknamer.R +++ b/R/chunknamer.R @@ -1,5 +1,5 @@ #' @title Interactively Add Names to Chunks -#' @description Addin that add in bulk a names to unnamed chunks in a +#' @description Addin that add in bulk a names to unnamed chunks in a #' Rmarkdown document in the source editor. #' @return NULL #' @details @@ -8,6 +8,12 @@ #' \code{\link{remedy_opts}}$set(name='ANOTHER NAME'). The names are then added in sequential order using #' an %02d naming scheme. #' +#' Setting the option to an empty string (\code{remedy_opts$set(name = "")}) disables +#' auto-naming entirely (the addin becomes a no-op). +#' +#' Only `r` chunks are renamed; chunks in other engines such as `stan`, +#' `python`, `sql` or `bash` are left untouched. +#' #' @rdname chunknamer #' @export chunknamer <- function(){ @@ -15,46 +21,84 @@ chunknamer <- function(){ adc <- rstudioapi::getSourceEditorContext() this <- adc$contents - - x <- grep('^```\\{(.*?)r',this) - - current_names <- gsub('```\\{(.*?)r|\\}|\\s+','',sapply(strsplit(this[x],','),'[',1)) - - #remove chunk options that are in first position - no_name_opts <- which(grepl('=',current_names)) - - current_names[no_name_opts] <- "" - + + new <- rename_chunks(this, name = remedy_opts$get("name")) + + changed <- which(this != new) + + for (idx in changed) { + rng <- Map(c, Map(c, idx, 1), Map(c, idx, Inf)) + rstudioapi::modifyRange(rng, new[idx], id = adc$id) + } + + invisible(NULL) +} + +#' Rename unnamed R chunks inside a character vector of lines +#' +#' Pure helper used by [chunknamer()]. Given the full contents of an Rmarkdown +#' document as a character vector (one line per element), it returns the same +#' vector with unnamed R chunks renamed using `name` as a stem and a +#' zero-padded counter. Chunks of engines other than `r` (e.g. `stan`, +#' `python`) are left untouched. +#' +#' If \code{name} is \code{""} or \code{NA}, the function is a no-op and the +#' input is returned as-is. +#' +#' @param lines character vector of document lines. +#' @param name character(1), stem used for new chunk names. Use \code{""} to +#' disable auto-naming. +#' +#' @return a character vector the same length as \code{lines}. +#' @keywords internal +#' @export +rename_chunks <- function(lines, name = "remedy") { + + if (is.null(name) || length(name) == 0L || is.na(name) || !nzchar(name)) { + return(lines) + } + + # Header of an R chunk: ```{r}, ```{r ...}, ```{r, ...}. + # knitr also accepts ```{R} (capital R), so match both cases. + r_chunk_re <- "^```\\{[rR](\\s|,|\\})" + + idx_r <- grep(r_chunk_re, lines) + if (!length(idx_r)) return(lines) + + # Extract current chunk "name" token (first comma-separated arg after {r) + first_tokens <- vapply( + strsplit(lines[idx_r], ","), `[`, character(1), 1 + ) + current_names <- gsub("```\\{[rR]|\\}|\\s+", "", first_tokens) + + # If the first token is an `x=y` option (no chunk name), treat as missing name + has_option_in_first <- grepl("=", current_names) + current_names[has_option_in_first] <- "" + no_name <- which(!nzchar(current_names)) - - counter_size <- pmax(nchar(as.character(length(no_name))) - 1,2) - - counter <- paste0('%0',counter_size,'d') - - for(i in seq_along(no_name)){ - + if (!length(no_name)) return(lines) + + counter_size <- max(nchar(as.character(length(no_name))), 2L) + counter <- paste0("%0", counter_size, "d") + + taken <- current_names + for (i in seq_along(no_name)) { bump <- 0 - - new_name <- sprintf(paste0('%s',counter),remedy_opts$get('name'),(i + bump)) - - # in case new name already exists bump counter until unused name is found - while(new_name%in%current_names){ - bump <- bump + 1 - new_name <- sprintf(paste0('%s',counter),remedy_opts$get('name'),(i + bump)) - } - - comma <- ifelse(no_name[i]%in%no_name_opts,',','') - - this[x][no_name[i]] <- gsub('^```\\{(.*?)r', - sprintf('```{r %s%s',new_name,comma), - this[x][no_name[i]] + new_name <- sprintf(paste0("%s", counter), name, (i + bump)) + while (new_name %in% taken) { + bump <- bump + 1 + new_name <- sprintf(paste0("%s", counter), name, (i + bump)) + } + taken <- c(taken, new_name) + + line_idx <- idx_r[no_name[i]] + comma <- if (has_option_in_first[no_name[i]]) "," else "" + lines[line_idx] <- sub( + "^```\\{[rR]", + sprintf("```{r %s%s", new_name, comma), + lines[line_idx] ) - - idx <- x[no_name][i] - - rng <- Map(c, Map(c, idx, 1), Map(c, idx, Inf)) - - rstudioapi::modifyRange(rng,this[x][no_name[i]],id = adc$id) } - + + lines } diff --git a/R/cite_packages.R b/R/cite_packages.R new file mode 100644 index 0000000..f242be6 --- /dev/null +++ b/R/cite_packages.R @@ -0,0 +1,70 @@ +#' Discover package names referenced in a character vector +#' +#' Scans lines for `library(pkg)`, `require(pkg)`, `requireNamespace("pkg")` +#' and `pkg::fun` patterns and returns the set of package names. Quoted and +#' unquoted spellings (`library(dplyr)` and `library("dplyr")`) collapse to +#' the same name. +#' +#' @param lines character vector of lines. +#' +#' @return a character vector of unique package names. +#' @export +packages_in_text <- function(lines) { + text <- paste(lines, collapse = "\n") + # R package names: start with a letter, then letters/digits/dot. + pkg_re <- "[A-Za-z][A-Za-z0-9.]*" + pkgs <- character() + pkgs <- c(pkgs, regmatches_group( + text, sprintf("library\\(\\s*['\"]?(%s)", pkg_re))) + pkgs <- c(pkgs, regmatches_group( + text, sprintf("require\\(\\s*['\"]?(%s)", pkg_re))) + pkgs <- c(pkgs, regmatches_group( + text, sprintf("requireNamespace\\(\\s*['\"]?(%s)", pkg_re))) + pkgs <- c(pkgs, regmatches_group( + text, sprintf("(%s)::", pkg_re))) + unique(pkgs[nzchar(pkgs)]) +} + +# Returns the first capture group of every match of `pattern` in `text`. +regmatches_group <- function(text, pattern) { + m <- gregexpr(pattern, text, perl = TRUE)[[1L]] + if (length(m) == 1L && m == -1L) return(character()) + starts <- attr(m, "capture.start")[, 1L] + lengths <- attr(m, "capture.length")[, 1L] + substring(text, starts, starts + lengths - 1L) +} + +#' Render a bulleted list of package citations +#' +#' Given a character vector of package names, returns a single string with one +#' markdown bullet per package, each containing the output of +#' `utils::citation()` formatted as plain text. Intended as the text payload of +#' a future RStudio addin. +#' +#' @param pkgs character vector of package names. If `NULL`, `pkgs` is +#' discovered via [packages_in_text()] on the lines in +#' `rstudioapi::getSourceEditorContext()$contents`. +#' @param lines character vector of lines to scan when `pkgs` is `NULL`. +#' +#' @return a single character string. +#' @export +cite_packages <- function(pkgs = NULL, lines = NULL) { + if (is.null(pkgs)) { + if (is.null(lines)) { + lines <- tryCatch( + rstudioapi::getSourceEditorContext()$contents, + error = function(e) character() + ) + } + pkgs <- packages_in_text(lines) + } + pkgs <- sort(unique(pkgs)) + bullets <- vapply(pkgs, function(p) { + cit <- tryCatch( + paste(format(utils::citation(p)), collapse = " "), + error = function(e) sprintf("%s (citation unavailable)", p) + ) + sprintf("+ %s", cit) + }, character(1), USE.NAMES = FALSE) + paste(bullets, collapse = "\n") +} diff --git a/R/headr.R b/R/headr.R index 9b46209..ac4921f 100644 --- a/R/headr.R +++ b/R/headr.R @@ -37,3 +37,56 @@ h5r <- function() add_prefix("##### ") #' @rdname header #' @export h6r <- function() add_prefix("###### ") + +#' Cycle the header level of a line +#' +#' Pure helper used by [headr_toggler()]. Given a character vector of lines, +#' each line's header level is promoted by one step: plain text becomes `# H1`, +#' `H1` becomes `## H2`, etc. `###### H6` cycles back to plain text (issue #59). +#' Empty lines pass through unchanged. +#' +#' @param lines character vector of lines. +#' +#' @return a character vector the same length as `lines`. +#' @export +toggle_header <- function(lines) { + vapply(lines, function(line) { + if (!nzchar(line)) return(line) + m <- regmatches(line, regexpr("^#{1,6}\\s+", line)) + if (length(m) == 0L) { + return(paste0("# ", line)) + } + level <- nchar(sub("\\s+$", "", m)) + rest <- sub("^#{1,6}\\s+", "", line) + if (level >= 6L) { + return(rest) + } + paste0(strrep("#", level + 1L), " ", rest) + }, character(1), USE.NAMES = FALSE) +} + +#' Cycle the header level on the current line +#' +#' RStudio addin wrapping [toggle_header()]. Rewrites each line intersecting +#' the current selection in-place. +#' +#' @return Invisible NULL. Used for its side effect on the active document. +#' @export +headr_toggler <- function() { + adc <- rstudioapi::getActiveDocumentContext() + sel <- adc$selection[[1L]]$range + start_row <- sel$start[[1L]] + end_row <- sel$end[[1L]] + if (end_row < start_row) end_row <- start_row + + lines <- adc$contents[start_row:end_row] + new_lines <- toggle_header(lines) + + last <- lines[length(lines)] + rng <- rstudioapi::document_range( + start = rstudioapi::document_position(start_row, 1L), + end = rstudioapi::document_position(end_row, nchar(last) + 1L) + ) + rstudioapi::modifyRange(rng, paste(new_lines, collapse = "\n"), id = adc$id) + invisible(NULL) +} diff --git a/R/opts.R b/R/opts.R index bc2dbe3..f27034e 100644 --- a/R/opts.R +++ b/R/opts.R @@ -58,27 +58,22 @@ new_defaults = function(value = list()) { #' @note \code{remedy_opts_current} is read-only in the sense that it does nothing if #' you call \code{remedy_opts_current$set()}; you can only query the options via #' \code{remedy_opts_current$get()}. -#' @export -#' @rdname remedyOpts -#' @examples remedy_opts$get() -remedy_opts <- new_defaults(list( - basic=FALSE, - name='remedy', - counter=TRUE, - chunk_opts=NULL, - kable_opts=NULL, - full_doc=FALSE, - token_purl='^#{2} -{4}(.*?)-{4,}$', - token_url = "^(?:(?:https?|ftp|file)://|www\\.|ftp\\.)[A-z0-9+&@#/%=~_|$?!:,.-]*[A-z0-9+&@#/%=~_|$]$", # URL regex - token_rel_link = "^.*[/|\\.][^\\.]+$", # Relative link regex - token_img_link = c('jpeg','jpg','png','gif'), # Image link - youtube_output = 'html', - youtube_width = '100%', - youtube_height = '400', - hotkeys = c( +#' @name remedyOpts +NULL + +default_hotkeys_impl <- function(os = Sys.info()[["sysname"]]) { + base <- c( + align_arrow = "Ctrl+Cmd+Alt+A", + align_equal = "Ctrl+Cmd+Alt+E", + align_tilde = "Ctrl+Cmd+Alt+T", + align_pipe = "Ctrl+Cmd+Alt+P", backtick = "Ctrl+Cmd+`", bold = "Ctrl+Cmd+B", - chunk = "Ctrl+Alt+Cmd+C", + br = "Ctrl+Cmd+Shift+B", + # `chunk` was historically Ctrl+Alt+Cmd+C, which on non-macOS collapses + # to Ctrl+Alt+C and clashes with `htmlcomment`. Use a Shift-bearing + # combo instead so the non-Cmd rewrite still yields a unique chord. + chunk = "Ctrl+Cmd+Shift+I", chunksplit = "Ctrl+Shift+Alt+C", chunkname = "Ctrl+Shift+Alt+N", footnote = "Ctrl+Cmd+Shift+6", @@ -100,8 +95,62 @@ remedy_opts <- new_defaults(list( xaringan = "Ctrl+Cmd+X", youtube = "Ctrl+Cmd+Y" ) + + if (!identical(os, "Darwin")) { + base <- gsub("Cmd", "Ctrl", base, fixed = TRUE) + # Collapse repeated "Ctrl+Ctrl" that can result from the substitution. + base <- gsub("Ctrl\\+Ctrl(\\+|$)", "Ctrl\\1", base) + } + + dup <- base[duplicated(base)] + if (length(dup)) { + stop( + "default_hotkeys() produced colliding shortcuts on os = '", os, "': ", + paste(unique(dup), collapse = ", "), + ".\nUpdate the base map so the Cmd->Ctrl rewrite stays collision-free.", + call. = FALSE + ) + } + + base +} + +#' @export +#' @rdname remedyOpts +#' @examples remedy_opts$get() +remedy_opts <- new_defaults(list( + basic=FALSE, + name='remedy', + counter=TRUE, + chunk_opts=NULL, + kable_opts=NULL, + full_doc=FALSE, + token_purl='^#{2} -{4}(.*?)-{4,}$', + token_url = "^(?:(?:https?|ftp|file)://|www\\.|ftp\\.)[A-z0-9+&@#/%=~_|$?!:,.-]*[A-z0-9+&@#/%=~_|$]$", # URL regex + token_rel_link = "^.*[/|\\.][^\\.]+$", # Relative link regex + token_img_link = c('jpeg','jpg','png','gif'), # Image link + youtube_output = 'html', + youtube_width = '100%', + youtube_height = '400', + hotkeys = default_hotkeys_impl() )) +#' Default remedy hotkeys +#' +#' Returns the default keyboard shortcut map used by [remedy_opts]. The base +#' map is expressed with `Cmd` (macOS convention). On non-macOS platforms, +#' `Cmd` is rewritten to `Ctrl` (see #70). Users can still override the full +#' map with `remedy_opts$set(hotkeys = ...)`. +#' +#' @param os character(1), operating system name as returned by +#' `Sys.info()[["sysname"]]`. Defaults to the current platform. +#' +#' @return a named character vector of hotkeys. +#' @export +default_hotkeys <- function(os = Sys.info()[["sysname"]]) { + default_hotkeys_impl(os = os) +} + #' @rdname remedyOpts #' @export remedy_opts_current <- new_defaults() diff --git a/man/align_arrow.Rd b/man/align_arrow.Rd index 37d8c67..3b89dc0 100644 --- a/man/align_arrow.Rd +++ b/man/align_arrow.Rd @@ -16,8 +16,8 @@ Align a highlighted region's assignment operators. \dontrun{ remedy_example( c( "# Align arrows", - "a <- 12", - "aaa <- 13"), + "a <- 12", + "aaa <- 13"), align_arrow ) } diff --git a/man/align_equal.Rd b/man/align_equal.Rd index 98f3466..0ed7ec9 100644 --- a/man/align_equal.Rd +++ b/man/align_equal.Rd @@ -17,8 +17,8 @@ Align a highlighted region's assignment operators. \dontrun{ remedy_example( c( "# Align equal signs", - "a = 12", - "aaa = 13"), + "a = 12", + "aaa = 13"), align_equal ) } diff --git a/man/align_pipe.Rd b/man/align_pipe.Rd new file mode 100644 index 0000000..ababd51 --- /dev/null +++ b/man/align_pipe.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/align.R +\name{align_pipe} +\alias{align_pipe} +\title{Align `|` columns in a markdown table} +\usage{ +align_pipe() +} +\value{ +Modifies the selected region in-place in the RStudio editor. +} +\description{ +Pads cells in a highlighted markdown table so the `|` separators line up. +} +\examples{ +\dontrun{ +remedy_example( + c( + "|a|bb|ccc|", + "|1|2|3|" + ), + align_pipe +) +} +} diff --git a/man/align_pipe_text.Rd b/man/align_pipe_text.Rd new file mode 100644 index 0000000..f3fa476 --- /dev/null +++ b/man/align_pipe_text.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/align.R +\name{align_pipe_text} +\alias{align_pipe_text} +\title{Align pipe (`|`) columns in a markdown table} +\usage{ +align_pipe_text(lines) +} +\arguments{ +\item{lines}{character vector of lines.} +} +\value{ +a character vector the same length as `lines`. +} +\description{ +Pure helper used by [align_pipe()]. Each line that contains a `|` is +treated as a table row; cells are right-padded so the `|` separators +line up across rows. Non-pipe lines pass through untouched. +} +\keyword{internal} diff --git a/man/align_text.Rd b/man/align_text.Rd new file mode 100644 index 0000000..f2299b3 --- /dev/null +++ b/man/align_text.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/align.R +\name{align_text} +\alias{align_text} +\title{Pad a character vector so the first occurrence of `find` lines up} +\usage{ +align_text(lines, find, fixed = TRUE) +} +\arguments{ +\item{lines}{character vector of lines.} + +\item{find}{character(1), a regular expression (unless `fixed = TRUE`).} + +\item{fixed}{logical, passed to `regexpr`.} +} +\value{ +a character vector the same length as `lines`. +} +\description{ +Pure helper shared by the `align_*` addins. Adds spaces before the +first match of `find` on each line so the matches sit at the same column. +Lines without a match are left untouched. +} +\keyword{internal} diff --git a/man/align_tilde.Rd b/man/align_tilde.Rd new file mode 100644 index 0000000..f90838f --- /dev/null +++ b/man/align_tilde.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/align.R +\name{align_tilde} +\alias{align_tilde} +\title{Align a highlighted region's tildes (`~`)} +\usage{ +align_tilde() +} +\value{ +Aligns the first `~` on each highlighted line. +} +\description{ +Useful e.g. for the right-hand side of `case_when()` branches. +} +\examples{ +\dontrun{ +remedy_example( + c( "# Align tildes", + "a ~ 12", + "aaa ~ 13"), + align_tilde + ) +} +} diff --git a/man/br_r.Rd b/man/br_r.Rd new file mode 100644 index 0000000..c63b106 --- /dev/null +++ b/man/br_r.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/br.R +\name{br_r} +\alias{br_r} +\title{Insert a HTML line break} +\usage{ +br_r() +} +\value{ +Invisible NULL. Used for its side effect on the active document. +} +\description{ +RStudio addin that inserts an HTML `
` line break at the cursor +(issue #60). Handy when you want a visible blank line between paragraphs +in knitted output. +} diff --git a/man/chunknamer.Rd b/man/chunknamer.Rd index c5841d0..05cdb4d 100644 --- a/man/chunknamer.Rd +++ b/man/chunknamer.Rd @@ -7,11 +7,17 @@ chunknamer() } \description{ -Addin that add in bulk a names to unnamed chunks in a +Addin that add in bulk a names to unnamed chunks in a Rmarkdown document in the source editor. } \details{ By default the addin will use "remedy" as the stem of the chunk names. this an be changed using \code{\link{remedy_opts}}$set(name='ANOTHER NAME'). The names are then added in sequential order using an %02d naming scheme. + +Setting the option to an empty string (\code{remedy_opts$set(name = "")}) disables +auto-naming entirely (the addin becomes a no-op). + +Only `r` chunks are renamed; chunks in other engines such as `stan`, +`python`, `sql` or `bash` are left untouched. } diff --git a/man/cite_packages.Rd b/man/cite_packages.Rd new file mode 100644 index 0000000..67faf4a --- /dev/null +++ b/man/cite_packages.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cite_packages.R +\name{cite_packages} +\alias{cite_packages} +\title{Render a bulleted list of package citations} +\usage{ +cite_packages(pkgs = NULL, lines = NULL) +} +\arguments{ +\item{pkgs}{character vector of package names. If `NULL`, `pkgs` is +discovered via [packages_in_text()] on the lines in +`rstudioapi::getSourceEditorContext()$contents`.} + +\item{lines}{character vector of lines to scan when `pkgs` is `NULL`.} +} +\value{ +a single character string. +} +\description{ +Given a character vector of package names, returns a single string with one +markdown bullet per package, each containing the output of +`utils::citation()` formatted as plain text. Intended as the text payload of +a future RStudio addin. +} diff --git a/man/default_hotkeys.Rd b/man/default_hotkeys.Rd new file mode 100644 index 0000000..dff2496 --- /dev/null +++ b/man/default_hotkeys.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/opts.R +\name{default_hotkeys} +\alias{default_hotkeys} +\title{Default remedy hotkeys} +\usage{ +default_hotkeys(os = Sys.info()[["sysname"]]) +} +\arguments{ +\item{os}{character(1), operating system name as returned by +`Sys.info()[["sysname"]]`. Defaults to the current platform.} +} +\value{ +a named character vector of hotkeys. +} +\description{ +Returns the default keyboard shortcut map used by [remedy_opts]. The base +map is expressed with `Cmd` (macOS convention). On non-macOS platforms, +`Cmd` is rewritten to `Ctrl` (see #70). Users can still override the full +map with `remedy_opts$set(hotkeys = ...)`. +} diff --git a/man/headr_toggler.Rd b/man/headr_toggler.Rd new file mode 100644 index 0000000..2d60855 --- /dev/null +++ b/man/headr_toggler.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/headr.R +\name{headr_toggler} +\alias{headr_toggler} +\title{Cycle the header level on the current line} +\usage{ +headr_toggler() +} +\value{ +Invisible NULL. Used for its side effect on the active document. +} +\description{ +RStudio addin wrapping [toggle_header()]. Rewrites each line intersecting +the current selection in-place. +} diff --git a/man/packages_in_text.Rd b/man/packages_in_text.Rd new file mode 100644 index 0000000..4337b7c --- /dev/null +++ b/man/packages_in_text.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cite_packages.R +\name{packages_in_text} +\alias{packages_in_text} +\title{Discover package names referenced in a character vector} +\usage{ +packages_in_text(lines) +} +\arguments{ +\item{lines}{character vector of lines.} +} +\value{ +a character vector of unique package names. +} +\description{ +Scans lines for `library(pkg)`, `require(pkg)`, `requireNamespace("pkg")` +and `pkg::fun` patterns and returns the set of package names. Quoted and +unquoted spellings (`library(dplyr)` and `library("dplyr")`) collapse to +the same name. +} diff --git a/man/remedy-package.Rd b/man/remedy-package.Rd index 7ed4614..2236959 100644 --- a/man/remedy-package.Rd +++ b/man/remedy-package.Rd @@ -6,11 +6,7 @@ \alias{remedy-package} \title{remedy: 'RStudio' Addins to Simplify 'Markdown' Writing} \description{ -An 'RStudio' addin providing shortcuts for writing in 'Markdown'. This package provides a series of - functions that allow the user to be more efficient when using 'Markdown'. For example, you can select - a word, and put it in bold or in italics, or change the alignment of elements inside you Rmd. The idea - is to map all the functionalities from 'remedy' on keyboard shortcuts, so that it provides an interface - close to what you can find in any other text editor. +An 'RStudio' addin providing shortcuts for writing in 'Markdown'. This package provides a series of functions that allow the user to be more efficient when using 'Markdown'. For example, you can select a word, and put it in bold or in italics, or change the alignment of elements inside you Rmd. The idea is to map all the functionalities from 'remedy' on keyboard shortcuts, so that it provides an interface close to what you can find in any other text editor. } \seealso{ Useful links: @@ -21,19 +17,19 @@ Useful links: } \author{ -\strong{Maintainer}: Colin Fay \email{contact@colinfay.me} (0000-0001-7343-1846) +\strong{Maintainer}: Colin Fay \email{contact@colinfay.me} (\href{https://orcid.org/0000-0001-7343-1846}{ORCID}) Authors: \itemize{ - \item Jonathan Sidi \email{yonicd@gmail.com} (0000-0002-4222-1819) + \item Jonathan Sidi \email{yonicd@gmail.com} (\href{https://orcid.org/0000-0002-4222-1819}{ORCID}) \item Luke Smith \email{luke@protocolvital.info} (author of seasmith/AlignAssign) } Other contributors: \itemize{ - \item Jonathan Carroll \email{rpkg@jcarroll.com.au} (0000-0002-1404-5264) [contributor] - \item Andrzej Oleś \email{andrzej.oles@gmail.com} (0000-0003-0285-2787) [contributor] - \item Daniel Possenriede \email{possenriede@gmail.com} (0000-0002-6738-9845) [contributor] + \item Jonathan Carroll \email{rpkg@jcarroll.com.au} (\href{https://orcid.org/0000-0002-1404-5264}{ORCID}) [contributor] + \item Andrzej Oleś \email{andrzej.oles@gmail.com} (\href{https://orcid.org/0000-0003-0285-2787}{ORCID}) [contributor] + \item Daniel Possenriede \email{possenriede@gmail.com} (\href{https://orcid.org/0000-0002-6738-9845}{ORCID}) [contributor] \item ThinkR [copyright holder] } diff --git a/man/remedyOpts.Rd b/man/remedyOpts.Rd index 32a627c..039d50e 100644 --- a/man/remedyOpts.Rd +++ b/man/remedyOpts.Rd @@ -1,7 +1,8 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/opts.R \docType{data} -\name{remedy_opts} +\name{remedyOpts} +\alias{remedyOpts} \alias{remedy_opts} \alias{remedy_opts_current} \title{Default and current remedy options} diff --git a/man/rename_chunks.Rd b/man/rename_chunks.Rd new file mode 100644 index 0000000..277b329 --- /dev/null +++ b/man/rename_chunks.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chunknamer.R +\name{rename_chunks} +\alias{rename_chunks} +\title{Rename unnamed R chunks inside a character vector of lines} +\usage{ +rename_chunks(lines, name = "remedy") +} +\arguments{ +\item{lines}{character vector of document lines.} + +\item{name}{character(1), stem used for new chunk names. Use \code{""} to +disable auto-naming.} +} +\value{ +a character vector the same length as \code{lines}. +} +\description{ +Pure helper used by [chunknamer()]. Given the full contents of an Rmarkdown +document as a character vector (one line per element), it returns the same +vector with unnamed R chunks renamed using `name` as a stem and a +zero-padded counter. Chunks of engines other than `r` (e.g. `stan`, +`python`) are left untouched. +} +\details{ +If \code{name} is \code{""} or \code{NA}, the function is a no-op and the +input is returned as-is. +} +\keyword{internal} diff --git a/man/toggle_header.Rd b/man/toggle_header.Rd new file mode 100644 index 0000000..3e27ca0 --- /dev/null +++ b/man/toggle_header.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/headr.R +\name{toggle_header} +\alias{toggle_header} +\title{Cycle the header level of a line} +\usage{ +toggle_header(lines) +} +\arguments{ +\item{lines}{character vector of lines.} +} +\value{ +a character vector the same length as `lines`. +} +\description{ +Pure helper used by [headr_toggler()]. Given a character vector of lines, +each line's header level is promoted by one step: plain text becomes `# H1`, +`H1` becomes `## H2`, etc. `###### H6` cycles back to plain text (issue #59). +Empty lines pass through unchanged. +} diff --git a/tests/testthat/test-align_extra.R b/tests/testthat/test-align_extra.R new file mode 100644 index 0000000..6aaf966 --- /dev/null +++ b/tests/testthat/test-align_extra.R @@ -0,0 +1,82 @@ +testthat::context("align helpers: tilde and pipe") + +testthat::describe("align_text", { + + it("aligns `~` across lines (issue #67)", { + input <- c( + "a ~ 1", + "aaa ~ 2", + "aa ~ 3" + ) + out <- align_text(input, find = "~") + testthat::expect_equal( + out, + c( + "a ~ 1", + "aaa ~ 2", + "aa ~ 3" + ) + ) + }) + + it("aligns `=` (regression)", { + input <- c( + "a = 1", + "aaa = 2" + ) + out <- align_text(input, find = "=") + testthat::expect_equal(out, c("a = 1", "aaa = 2")) + }) + + it("aligns `<-` for the align_arrow addin", { + input <- c( + "a <- 1", + "aaa <- 2" + ) + out <- align_text(input, find = "<-") + testthat::expect_equal(out, c("a <- 1", "aaa <- 2")) + }) +}) + +testthat::describe("align_pipe_text", { + + it("aligns `|` columns in a markdown table (issue #58)", { + input <- c( + "|a|bb|ccc|", + "|1|2|3|", + "|44|5|66|" + ) + out <- align_pipe_text(input) + testthat::expect_equal( + out, + c( + "|a |bb|ccc|", + "|1 |2 |3 |", + "|44|5 |66 |" + ) + ) + }) + + it("is a no-op on non-pipe text", { + input <- c("hello", "world") + out <- align_pipe_text(input) + testthat::expect_equal(out, input) + }) + + it("leaves lines without pipes untouched", { + input <- c( + "|a|bb|", + "no pipes here", + "|1|2|" + ) + out <- align_pipe_text(input) + testthat::expect_equal( + out, + c( + "|a|bb|", + "no pipes here", + "|1|2 |" + ) + ) + }) +}) diff --git a/tests/testthat/test-cite_packages.R b/tests/testthat/test-cite_packages.R new file mode 100644 index 0000000..a9f9d4f --- /dev/null +++ b/tests/testthat/test-cite_packages.R @@ -0,0 +1,51 @@ +testthat::context("cite_packages (#20)") + +testthat::describe("cite_packages", { + + it("formats a bulleted citation list for given packages", { + out <- cite_packages("stats") + testthat::expect_true(grepl("stats", out, fixed = TRUE)) + testthat::expect_true(grepl("^\\+\\s|\\n\\+\\s", out)) + }) + + it("returns one bullet per package", { + out <- cite_packages(c("stats", "utils")) + n_bullets <- length(gregexpr("(?m)^\\+ ", out, perl = TRUE)[[1L]]) + testthat::expect_equal(n_bullets, 2L) + }) + + it("uses `packages_in_text()` to discover packages in Rmd lines", { + lines <- c( + "```{r}", + "library(utils)", + "stats::lm(y ~ x)", + "```" + ) + pkgs <- packages_in_text(lines) + testthat::expect_true("utils" %in% pkgs) + testthat::expect_true("stats" %in% pkgs) + }) + + it("strips quotes around library() arguments (regression)", { + pkgs <- packages_in_text(c( + "library(\"dplyr\")", + "library('tidyr')", + "library(stats)" + )) + testthat::expect_setequal(pkgs, c("dplyr", "tidyr", "stats")) + }) + + it("collapses quoted and unquoted spellings to the same name", { + pkgs <- packages_in_text(c( + "library(dplyr)", + "library(\"dplyr\")", + "requireNamespace('dplyr')" + )) + testthat::expect_equal(pkgs, "dplyr") + }) + + it("rejects names that don't start with a letter", { + pkgs <- packages_in_text("library(.hidden)") + testthat::expect_length(pkgs, 0L) + }) +}) diff --git a/tests/testthat/test-default_hotkeys.R b/tests/testthat/test-default_hotkeys.R new file mode 100644 index 0000000..6204238 --- /dev/null +++ b/tests/testthat/test-default_hotkeys.R @@ -0,0 +1,47 @@ +testthat::context("default_hotkeys: align hotkeys + OS awareness") + +testthat::describe("default_hotkeys", { + + it("exposes default shortcuts for the align_* addins (issue #29)", { + keys <- default_hotkeys(os = "Darwin") + testthat::expect_true(all( + c("align_arrow", "align_equal", "align_tilde", "align_pipe") %in% names(keys) + )) + }) + + it("uses Cmd on macOS", { + keys <- default_hotkeys(os = "Darwin") + testthat::expect_true(any(grepl("Cmd", keys, fixed = TRUE))) + }) + + it("replaces Cmd with Ctrl on Windows (issue #70)", { + keys <- default_hotkeys(os = "Windows") + testthat::expect_false(any(grepl("Cmd", keys, fixed = TRUE))) + }) + + it("replaces Cmd with Ctrl on Linux (issue #70)", { + keys <- default_hotkeys(os = "Linux") + testthat::expect_false(any(grepl("Cmd", keys, fixed = TRUE))) + }) + + it("default remedy_opts uses the current OS map", { + keys <- remedy_opts$get("hotkeys") + expected <- default_hotkeys() + testthat::expect_equal(keys, expected) + }) + + it("includes br addin shortcut (issue #60)", { + keys <- default_hotkeys(os = "Darwin") + testthat::expect_true("br" %in% names(keys)) + }) + + it("does not produce colliding shortcuts on any OS (regression)", { + for (os in c("Darwin", "Linux", "Windows")) { + keys <- default_hotkeys(os = os) + testthat::expect_equal( + length(keys), length(unique(keys)), + info = paste("collision detected on", os) + ) + } + }) +}) diff --git a/tests/testthat/test-headr_toggle.R b/tests/testthat/test-headr_toggle.R new file mode 100644 index 0000000..05b56ae --- /dev/null +++ b/tests/testthat/test-headr_toggle.R @@ -0,0 +1,28 @@ +testthat::context("header toggle (#59) + line break (#60)") + +testthat::describe("toggle_header", { + + it("turns a plain line into H1", { + testthat::expect_equal(toggle_header("foo"), "# foo") + }) + + it("promotes H1 to H2 (and so on)", { + testthat::expect_equal(toggle_header("# foo"), "## foo") + testthat::expect_equal(toggle_header("## foo"), "### foo") + }) + + it("wraps around at H6 back to plain text", { + testthat::expect_equal(toggle_header("###### foo"), "foo") + }) + + it("is a no-op on empty strings", { + testthat::expect_equal(toggle_header(""), "") + }) + + it("works vectorised", { + testthat::expect_equal( + toggle_header(c("foo", "# bar", "###### baz")), + c("# foo", "## bar", "baz") + ) + }) +}) diff --git a/tests/testthat/test-rename_chunks.R b/tests/testthat/test-rename_chunks.R new file mode 100644 index 0000000..6235379 --- /dev/null +++ b/tests/testthat/test-rename_chunks.R @@ -0,0 +1,114 @@ +testthat::context("rename_chunks (pure helper)") + +testthat::describe("rename_chunks", { + + it("names unnamed R chunks sequentially", { + input <- c( + "```{r}", + "plot(1)", + "```", + "", + "```{r}", + "plot(2)", + "```" + ) + out <- rename_chunks(input, name = "remedy") + testthat::expect_equal( + out, + c( + "```{r remedy01}", + "plot(1)", + "```", + "", + "```{r remedy02}", + "plot(2)", + "```" + ) + ) + }) + + it("leaves already-named R chunks untouched", { + input <- c("```{r foo}", "1+1", "```") + out <- rename_chunks(input, name = "remedy") + testthat::expect_equal(out, input) + }) + + it("keeps stan chunks unchanged (issue #57)", { + input <- c( + "```{r}", + "", + "```", + "", + "```{stan output.var=\"test\"}", + "", + "```" + ) + out <- rename_chunks(input, name = "remedy") + testthat::expect_equal( + out, + c( + "```{r remedy01}", + "", + "```", + "", + "```{stan output.var=\"test\"}", + "", + "```" + ) + ) + }) + + it("keeps python chunks unchanged", { + input <- c("```{python}", "print('hi')", "```") + out <- rename_chunks(input, name = "remedy") + testthat::expect_equal(out, input) + }) + + it("preserves existing chunk options when adding a name", { + input <- c("```{r, echo = FALSE}", "1+1", "```") + out <- rename_chunks(input, name = "remedy") + testthat::expect_equal( + out, + c("```{r remedy01, echo = FALSE}", "1+1", "```") + ) + }) + + it("zero-pads to enough digits for >= 100 unnamed chunks (regression)", { + # 100 empty R chunks should produce names remedy001..remedy100, all + # with the same width so lexical sort matches numeric order. + chunks <- as.character(rep(c("```{r}", "```", ""), 100)) + out <- rename_chunks(chunks, name = "remedy") + headers <- grep("^```\\{r remedy", out, value = TRUE) + testthat::expect_equal(length(headers), 100L) + nums <- sub("^```\\{r remedy([0-9]+)\\}$", "\\1", headers) + widths <- nchar(nums) + testthat::expect_true(length(unique(widths)) == 1L) + testthat::expect_equal(unique(widths), 3L) + }) + + it("matches both lowercase {r} and uppercase {R} chunk headers", { + # knitr accepts both spellings; only matching lowercase used to leave + # `R` chunks unnamed forever (self-review). + input <- c( + "```{R}", + "plot(1)", + "```" + ) + out <- rename_chunks(input, name = "remedy") + testthat::expect_equal(out[1], "```{r remedy01}") + }) + + it("does not rename when name is empty (issue #73)", { + input <- c( + "```{r}", + "1", + "```", + "", + "```{r}", + "2", + "```" + ) + out <- rename_chunks(input, name = "") + testthat::expect_equal(out, input) + }) +})