From 12121bb1fd0a5930495cdd01e1cced017e5bffce Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Fri, 24 Apr 2026 22:31:20 +0200 Subject: [PATCH 1/9] fix(chunknamer): ignore non-R engine chunks; support empty name - Add pure helper rename_chunks() and refactor chunknamer() around it. - Tighten header regex so stan/python/sql/bash chunks are left untouched (#57). - Treat name = "" (or NA) as a no-op to disable auto-naming (#73). --- DESCRIPTION | 2 +- NAMESPACE | 1 + NEWS.md | 5 ++ R/chunknamer.R | 121 +++++++++++++++++++--------- man/chunknamer.Rd | 8 +- man/remedy-package.Rd | 16 ++-- man/rename_chunks.Rd | 29 +++++++ tests/testthat/test-rename_chunks.R | 89 ++++++++++++++++++++ 8 files changed, 220 insertions(+), 51 deletions(-) create mode 100644 man/rename_chunks.Rd create mode 100644 tests/testthat/test-rename_chunks.R 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..9ffcc82 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -28,6 +28,7 @@ export(olistr) export(remedy_example) export(remedy_opts) export(remedy_opts_current) +export(rename_chunks) export(rightr) export(scratch_file) export(set_text) diff --git a/NEWS.md b/NEWS.md index 838ef1c..35e4ac6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,10 @@ # 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). * Clean pkgdown on github * Added travis support and codecov. diff --git a/R/chunknamer.R b/R/chunknamer.R index bada1a6..dfe4a55 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,83 @@ 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, ...} + r_chunk_re <- "^```\\{r(\\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("```\\{r|\\}|\\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 <- pmax(nchar(as.character(length(no_name))) - 1, 2) + 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( + "^```\\{r", + 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/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/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/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/tests/testthat/test-rename_chunks.R b/tests/testthat/test-rename_chunks.R new file mode 100644 index 0000000..f6ff162 --- /dev/null +++ b/tests/testthat/test-rename_chunks.R @@ -0,0 +1,89 @@ +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("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) + }) +}) From a9e56a42dfc1d6e6c8c3d959b7a39608b7329c6c Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Fri, 24 Apr 2026 22:36:30 +0200 Subject: [PATCH 2/9] feat: align_tilde/align_pipe, br_r, header toggler + OS-aware hotkeys - align_tilde()/align_pipe() addins and their pure helpers align_text() / align_pipe_text() (#58, #67). - default_hotkeys() returns the default shortcut map; rewrites Cmd to Ctrl on non-macOS platforms (#70). Adds align_* and br entries (#29). - br_r() addin inserts
(#60). - headr_toggler() addin + toggle_header() pure helper cycle H1..H6..plain (#59). --- NAMESPACE | 8 ++ NEWS.md | 11 ++ R/align.R | 173 +++++++++++++++++++++----- R/br.R | 16 +++ R/headr.R | 53 ++++++++ R/opts.R | 71 ++++++++--- man/align_arrow.Rd | 4 +- man/align_equal.Rd | 4 +- man/align_pipe.Rd | 25 ++++ man/align_pipe_text.Rd | 20 +++ man/align_text.Rd | 24 ++++ man/align_tilde.Rd | 24 ++++ man/br_r.Rd | 16 +++ man/default_hotkeys.Rd | 21 ++++ man/headr_toggler.Rd | 15 +++ man/remedyOpts.Rd | 3 +- man/toggle_header.Rd | 20 +++ tests/testthat/test-align_extra.R | 73 +++++++++++ tests/testthat/test-default_hotkeys.R | 37 ++++++ tests/testthat/test-headr_toggle.R | 28 +++++ 20 files changed, 593 insertions(+), 53 deletions(-) create mode 100644 R/br.R create mode 100644 man/align_pipe.Rd create mode 100644 man/align_pipe_text.Rd create mode 100644 man/align_text.Rd create mode 100644 man/align_tilde.Rd create mode 100644 man/br_r.Rd create mode 100644 man/default_hotkeys.Rd create mode 100644 man/headr_toggler.Rd create mode 100644 man/toggle_header.Rd create mode 100644 tests/testthat/test-align_extra.R create mode 100644 tests/testthat/test-default_hotkeys.R create mode 100644 tests/testthat/test-headr_toggle.R diff --git a/NAMESPACE b/NAMESPACE index 9ffcc82..0d508c8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,12 +2,18 @@ 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(default_hotkeys) export(entire_document) export(footnoter) export(h1r) @@ -16,6 +22,7 @@ export(h3r) export(h4r) export(h5r) export(h6r) +export(headr_toggler) export(htmlcommentr) export(id_ref) export(imager) @@ -34,6 +41,7 @@ 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 35e4ac6..d8d6270 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,17 @@ `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). * Clean pkgdown on github * Added travis support and codecov. diff --git a/R/align.R b/R/align.R index 23d20af..df453cb 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,17 +58,86 @@ 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 +} + #' 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 #' ) #' } @@ -96,12 +154,12 @@ 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 #' ) #' } @@ -111,4 +169,59 @@ align_equal <- function() { loc <- find_regex("=", area) insertList <- assemble_insert(loc) insertr(insertList) -} \ No newline at end of file +} + +#' 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() { + capture <- capture() + area <- capture_area(capture) + loc <- find_regex("~", area) + insertList <- assemble_insert(loc) + insertr(insertList) +} + +#' 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/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..7854ef0 100644 --- a/R/opts.R +++ b/R/opts.R @@ -58,26 +58,18 @@ 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", + br = "Ctrl+Cmd+Shift+B", chunk = "Ctrl+Alt+Cmd+C", chunksplit = "Ctrl+Shift+Alt+C", chunkname = "Ctrl+Shift+Alt+N", @@ -100,8 +92,51 @@ 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 duplicate "Ctrl+Ctrl" that can result from the substitution + base <- gsub("Ctrl\\+Ctrl(\\+|$)", "Ctrl\\1", base) + } + 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/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/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/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..06328af --- /dev/null +++ b/tests/testthat/test-align_extra.R @@ -0,0 +1,73 @@ +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")) + }) +}) + +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-default_hotkeys.R b/tests/testthat/test-default_hotkeys.R new file mode 100644 index 0000000..07e9b27 --- /dev/null +++ b/tests/testthat/test-default_hotkeys.R @@ -0,0 +1,37 @@ +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)) + }) +}) 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") + ) + }) +}) From e08be45d67697ace93c145cca43d7ddf5cd8977e Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Fri, 24 Apr 2026 22:37:44 +0200 Subject: [PATCH 3/9] feat(cite_packages): bulleted citation list for packages in an Rmd (#20) --- NAMESPACE | 2 + NEWS.md | 3 ++ R/cite_packages.R | 62 +++++++++++++++++++++++++++++ man/cite_packages.Rd | 24 +++++++++++ man/packages_in_text.Rd | 18 +++++++++ tests/testthat/test-cite_packages.R | 28 +++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 R/cite_packages.R create mode 100644 man/cite_packages.Rd create mode 100644 man/packages_in_text.Rd create mode 100644 tests/testthat/test-cite_packages.R diff --git a/NAMESPACE b/NAMESPACE index 0d508c8..b7c458e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -13,6 +13,7 @@ export(br_r) export(chunknamer) export(chunkr) export(chunksplitr) +export(cite_packages) export(default_hotkeys) export(entire_document) export(footnoter) @@ -32,6 +33,7 @@ export(italicsr) export(latexr) export(listr) export(olistr) +export(packages_in_text) export(remedy_example) export(remedy_opts) export(remedy_opts_current) diff --git a/NEWS.md b/NEWS.md index d8d6270..1cacaf3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -16,6 +16,9 @@ * 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/cite_packages.R b/R/cite_packages.R new file mode 100644 index 0000000..0c0a42d --- /dev/null +++ b/R/cite_packages.R @@ -0,0 +1,62 @@ +#' 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. +#' +#' @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") + pkgs <- character() + pkgs <- c(pkgs, regmatches_all(text, + "(?<=library\\()\\s*['\"]?([.A-Za-z][.A-Za-z0-9]*)")) + pkgs <- c(pkgs, regmatches_all(text, + "(?<=require\\()\\s*['\"]?([.A-Za-z][.A-Za-z0-9]*)")) + pkgs <- c(pkgs, regmatches_all(text, + "(?<=requireNamespace\\()\\s*['\"]?([.A-Za-z][.A-Za-z0-9]*)")) + pkgs <- c(pkgs, regmatches_all(text, + "([.A-Za-z][.A-Za-z0-9]*)(?=::)")) + unique(pkgs[nzchar(pkgs)]) +} + +regmatches_all <- function(text, pattern) { + m <- gregexpr(pattern, text, perl = TRUE) + unlist(regmatches(text, m)) +} + +#' 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/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/packages_in_text.Rd b/man/packages_in_text.Rd new file mode 100644 index 0000000..045ce40 --- /dev/null +++ b/man/packages_in_text.Rd @@ -0,0 +1,18 @@ +% 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. +} diff --git a/tests/testthat/test-cite_packages.R b/tests/testthat/test-cite_packages.R new file mode 100644 index 0000000..6657cb3 --- /dev/null +++ b/tests/testthat/test-cite_packages.R @@ -0,0 +1,28 @@ +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) + }) +}) From 24eaca06113eeb99567fed35e7bb072fff2bb024 Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Sat, 25 Apr 2026 09:21:09 +0200 Subject: [PATCH 4/9] fix(cite_packages): strip quotes/whitespace from extracted package names The previous regex relied on a lookbehind, then matched the outer literal (whitespace + optional quote + name) but kept the lookbehind's right-hand side in the match. So library("dplyr") yielded '"dplyr', which never matched utils::citation() and showed up as a duplicate of 'dplyr' in the output. Switch to a capture-group extraction (regmatches_group helper) so the returned name is exactly the package identifier. Also tighten the regex to require a leading letter, matching valid R package names. --- R/cite_packages.R | 32 ++++++++++++++++++----------- man/packages_in_text.Rd | 4 +++- tests/testthat/test-cite_packages.R | 23 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/R/cite_packages.R b/R/cite_packages.R index 0c0a42d..f242be6 100644 --- a/R/cite_packages.R +++ b/R/cite_packages.R @@ -1,7 +1,9 @@ #' 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. +#' 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. #' @@ -9,21 +11,27 @@ #' @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_all(text, - "(?<=library\\()\\s*['\"]?([.A-Za-z][.A-Za-z0-9]*)")) - pkgs <- c(pkgs, regmatches_all(text, - "(?<=require\\()\\s*['\"]?([.A-Za-z][.A-Za-z0-9]*)")) - pkgs <- c(pkgs, regmatches_all(text, - "(?<=requireNamespace\\()\\s*['\"]?([.A-Za-z][.A-Za-z0-9]*)")) - pkgs <- c(pkgs, regmatches_all(text, - "([.A-Za-z][.A-Za-z0-9]*)(?=::)")) + 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)]) } -regmatches_all <- function(text, pattern) { - m <- gregexpr(pattern, text, perl = TRUE) - unlist(regmatches(text, m)) +# 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 diff --git a/man/packages_in_text.Rd b/man/packages_in_text.Rd index 045ce40..4337b7c 100644 --- a/man/packages_in_text.Rd +++ b/man/packages_in_text.Rd @@ -14,5 +14,7 @@ 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. +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/tests/testthat/test-cite_packages.R b/tests/testthat/test-cite_packages.R index 6657cb3..a9f9d4f 100644 --- a/tests/testthat/test-cite_packages.R +++ b/tests/testthat/test-cite_packages.R @@ -25,4 +25,27 @@ testthat::describe("cite_packages", { 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) + }) }) From dac92d57c02fae0029eed6831eb3c2a761bf9f55 Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Sat, 25 Apr 2026 09:21:17 +0200 Subject: [PATCH 5/9] fix(default_hotkeys): avoid Cmd->Ctrl collision between chunk and htmlcomment The Cmd->Ctrl rewrite used on non-macOS turned chunk's 'Ctrl+Alt+Cmd+C' into 'Ctrl+Alt+C', which collides with htmlcomment's existing 'Ctrl+Alt+C'. Move chunk to a Shift-bearing chord that survives the rewrite as a unique combo, and assert at runtime that the produced map has no duplicates so future additions catch their own collisions. --- R/opts.R | 18 ++++++++++++++++-- tests/testthat/test-default_hotkeys.R | 10 ++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/R/opts.R b/R/opts.R index 7854ef0..f27034e 100644 --- a/R/opts.R +++ b/R/opts.R @@ -70,7 +70,10 @@ default_hotkeys_impl <- function(os = Sys.info()[["sysname"]]) { backtick = "Ctrl+Cmd+`", bold = "Ctrl+Cmd+B", br = "Ctrl+Cmd+Shift+B", - chunk = "Ctrl+Alt+Cmd+C", + # `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", @@ -95,9 +98,20 @@ default_hotkeys_impl <- function(os = Sys.info()[["sysname"]]) { if (!identical(os, "Darwin")) { base <- gsub("Cmd", "Ctrl", base, fixed = TRUE) - # collapse duplicate "Ctrl+Ctrl" that can result from the substitution + # 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 } diff --git a/tests/testthat/test-default_hotkeys.R b/tests/testthat/test-default_hotkeys.R index 07e9b27..6204238 100644 --- a/tests/testthat/test-default_hotkeys.R +++ b/tests/testthat/test-default_hotkeys.R @@ -34,4 +34,14 @@ testthat::describe("default_hotkeys", { 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) + ) + } + }) }) From 41d6cf5aac8d872a97793515ca51a63efd522f15 Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Sat, 25 Apr 2026 09:21:25 +0200 Subject: [PATCH 6/9] refactor(align_*): route align_arrow/equal/tilde through align_text() The previous commit advertised align_text() as a 'shared pure helper' but the addins continued to use the legacy find_regex/assemble_insert chain; align_text() was exported and only exercised by tests. Introduce an internal align_addin(find, fixed) that captures the selection rows, runs align_text(), and writes the result back via a single rstudioapi::modifyRange call. align_arrow / align_equal / align_tilde are now one-line wrappers around it, so the same code path runs in tests and in RStudio. --- R/align.R | 37 ++++++++++++++++++------------- tests/testthat/test-align_extra.R | 9 ++++++++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/R/align.R b/R/align.R index df453cb..5eb5dbb 100644 --- a/R/align.R +++ b/R/align.R @@ -127,6 +127,25 @@ align_pipe_text <- function(lines) { 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. @@ -142,11 +161,7 @@ align_pipe_text <- function(lines) { #' ) #' } 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. @@ -164,11 +179,7 @@ align_arrow <- function() { #' ) #' } align_equal <- function() { - capture <- capture() - area <- capture_area(capture) - loc <- find_regex("=", area) - insertList <- assemble_insert(loc) - insertr(insertList) + align_addin("=") } #' Align a highlighted region's tildes (`~`) @@ -187,11 +198,7 @@ align_equal <- function() { #' ) #' } align_tilde <- function() { - capture <- capture() - area <- capture_area(capture) - loc <- find_regex("~", area) - insertList <- assemble_insert(loc) - insertr(insertList) + align_addin("~") } #' Align `|` columns in a markdown table diff --git a/tests/testthat/test-align_extra.R b/tests/testthat/test-align_extra.R index 06328af..6aaf966 100644 --- a/tests/testthat/test-align_extra.R +++ b/tests/testthat/test-align_extra.R @@ -27,6 +27,15 @@ testthat::describe("align_text", { 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", { From fb67cdf6599e61d1bf66bece73cf6de203a388ff Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Sat, 25 Apr 2026 09:21:32 +0200 Subject: [PATCH 7/9] fix(rename_chunks): widen counter for >= 100 unnamed chunks The previous formula (nchar(N) - 1, floored at 2) under-sized the zero-padding once the document had 100+ unnamed chunks: 'remedy01, remedy02, ..., remedy99, remedy100' with mismatched widths broke lexical ordering. Drop the -1 so width tracks length(no_name) exactly. --- R/chunknamer.R | 2 +- tests/testthat/test-rename_chunks.R | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/R/chunknamer.R b/R/chunknamer.R index dfe4a55..fcbc42a 100644 --- a/R/chunknamer.R +++ b/R/chunknamer.R @@ -77,7 +77,7 @@ rename_chunks <- function(lines, name = "remedy") { no_name <- which(!nzchar(current_names)) if (!length(no_name)) return(lines) - counter_size <- pmax(nchar(as.character(length(no_name))) - 1, 2) + counter_size <- max(nchar(as.character(length(no_name))), 2L) counter <- paste0("%0", counter_size, "d") taken <- current_names diff --git a/tests/testthat/test-rename_chunks.R b/tests/testthat/test-rename_chunks.R index f6ff162..cb1950a 100644 --- a/tests/testthat/test-rename_chunks.R +++ b/tests/testthat/test-rename_chunks.R @@ -73,6 +73,19 @@ testthat::describe("rename_chunks", { ) }) + 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("does not rename when name is empty (issue #73)", { input <- c( "```{r}", From 34fccf66a37c7558535bc830f6ce5f2670da751a Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Sat, 25 Apr 2026 15:45:36 +0200 Subject: [PATCH 8/9] ci: add R-CMD-check workflow (ubuntu-latest, r-lib/actions v2) --- .github/workflows/R-CMD-check.yaml | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/R-CMD-check.yaml 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"' From d3e960103c4ad4e77986f0e0aa62e9c3bdae3f48 Mon Sep 17 00:00:00 2001 From: Vincent Guyader Date: Sun, 26 Apr 2026 20:04:36 +0200 Subject: [PATCH 9/9] fix(rename_chunks): match both lowercase and uppercase R chunks (self-review) knitr accepts both ```{r} and ```{R}. The regex used to anchor on lowercase r only, so any R chunk in the source was left unnamed forever. Make all three regex spots case-insensitive to [rR] and add a regression test. --- R/chunknamer.R | 9 +++++---- tests/testthat/test-rename_chunks.R | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/R/chunknamer.R b/R/chunknamer.R index fcbc42a..8a79634 100644 --- a/R/chunknamer.R +++ b/R/chunknamer.R @@ -58,8 +58,9 @@ rename_chunks <- function(lines, name = "remedy") { return(lines) } - # Header of an R chunk: ```{r}, ```{r ...}, ```{r, ...} - r_chunk_re <- "^```\\{r(\\s|,|\\})" + # 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) @@ -68,7 +69,7 @@ rename_chunks <- function(lines, name = "remedy") { first_tokens <- vapply( strsplit(lines[idx_r], ","), `[`, character(1), 1 ) - current_names <- gsub("```\\{r|\\}|\\s+", "", first_tokens) + 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) @@ -93,7 +94,7 @@ rename_chunks <- function(lines, name = "remedy") { line_idx <- idx_r[no_name[i]] comma <- if (has_option_in_first[no_name[i]]) "," else "" lines[line_idx] <- sub( - "^```\\{r", + "^```\\{[rR]", sprintf("```{r %s%s", new_name, comma), lines[line_idx] ) diff --git a/tests/testthat/test-rename_chunks.R b/tests/testthat/test-rename_chunks.R index cb1950a..6235379 100644 --- a/tests/testthat/test-rename_chunks.R +++ b/tests/testthat/test-rename_chunks.R @@ -86,6 +86,18 @@ testthat::describe("rename_chunks", { 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}",