diff --git a/DESCRIPTION b/DESCRIPTION index 9b88f0f..46c8b83 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,4 +27,7 @@ Imports: utils, cli, roxygen2 -RoxygenNote: 7.2.3 +RoxygenNote: 7.3.2 +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 diff --git a/NAMESPACE b/NAMESPACE index f12358c..d413e52 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,6 +4,8 @@ S3method(check_linter,"function") S3method(check_linter,character) S3method(check_linter,default) S3method(check_linter,list) +S3method(config_citations,cff) +S3method(config_citations,default) S3method(lint_full_stop,character) S3method(lint_full_stop,default) S3method(lint_full_stop,roxy_tag) @@ -19,6 +21,24 @@ S3method(lint_starts_lowercase,roxy_tag) S3method(lint_title_case,character) S3method(lint_title_case,default) S3method(lint_title_case,roxy_tag) +S3method(rd_df,acronym) +S3method(rd_df,cite) +S3method(rd_df,code) +S3method(rd_df,default) +S3method(rd_df,dfn) +S3method(rd_df,email) +S3method(rd_df,env) +S3method(rd_df,file) +S3method(rd_df,href) +S3method(rd_df,item) +S3method(rd_df,itemize) +S3method(rd_df,kbd) +S3method(rd_df,option) +S3method(rd_df,pkg) +S3method(rd_df,preformatted) +S3method(rd_df,roxy_tag) +S3method(rd_df,url) +S3method(rd_df,verb) S3method(roxygen2::roclet_output,roclet_roxylint) S3method(roxygen2::roclet_process,roclet_roxylint) export(check_linter) diff --git a/R/check_linter.R b/R/check_linter.R index 0c19fec..8f36d5c 100644 --- a/R/check_linter.R +++ b/R/check_linter.R @@ -1,4 +1,4 @@ -#' Apply 'roxygen2' linters +#' Apply 'roxygen2' Linters `r # nolint: sentence_case.` #' #' Provided a list of linters for a given tag, iterate over linters to raise #' alerts during the documentation process. @@ -15,6 +15,8 @@ #' #' @export check_linter <- function(linters, tag, ...) { + # content was generated automatically with no originating text + if (is.null(tag$raw)) return(invisible(TRUE)) UseMethod("check_linter") } @@ -40,7 +42,7 @@ check_linter.list <- function(linters, tag, ...) { linter <- linters[[i]] message <- names(linters[i]) message <- if (!is.null(message) && nchar(message) > 0) message else NULL - check_linter(linter, tag, message = message) + check_linter(linter, tag, message = message, ...) } invisible(TRUE) } @@ -63,7 +65,9 @@ check_linter.list <- function(linters, tag, ...) { #' #' @export check_linter.function <- function(linters, tag, ...) { - into_roxy_alert(tag, do.call(linters, append(list(tag), tag$val))) + args <- list(tag, ...) + args <- append(args, tag$val) # append parsed terms from roxy tag + into_roxy_alert(tag, do.call(linters, args)) invisible(TRUE) } diff --git a/R/config.R b/R/config.R index 4077158..8595c8d 100644 --- a/R/config.R +++ b/R/config.R @@ -23,6 +23,11 @@ config_load <- function(path = getwd(), cache = TRUE) { roxylint <- new.env(parent = baseenv()) local_config <- config_find_from(path) + # discover citations + if (length(list.files(path, pattern = "^CITATION")) > 0) { + roxylint$citations <- config_citations(path) + } + # config linters for (tag in names(local_config$linters)) { add_linters( @@ -34,7 +39,7 @@ config_load <- function(path = getwd(), cache = TRUE) { } # add non-linter config - local_config$lintesr <- NULL + local_config$linters <- NULL for (n in names(local_config)) { roxylint[[n]] <- local_config[[n]] } @@ -121,3 +126,58 @@ config_from_file <- function(path) { res <- new.env() source(config_files[[1]], local = res)$value } + +config_citations <- function(path) { + UseMethod("config_citations") +} + +#' @export +config_citations.default <- function(path) { + if (file.info(path)$isdir) { + config_files <- list.files( + path, + pattern = "^CITATION", + full.names = TRUE, + recursive = TRUE + ) + + return(unlist(recursive = FALSE, lapply(config_files, function(f) { + config_citations(f) + }))) + } + + if (nchar(ext <- tools::file_ext(path)) > 0) { + class(path) <- c(tolower(ext), class(path)) + config_citations(path) + } +} + +#' @export +config_citations.cff <- function(path) { + if (!requireNamespace("cffr", quietly = TRUE)) { + cli::cli_alert_info( + "Package {.pkg cffr} is required to use citations to inform lints." + ) + return() + } + + derive_citation_data(cffr::cff_read(as.character(path))) +} + +derive_citation_data <- function(x) { + data <- list(cff = x) + + # derive proper nouns from authors lists + ref_author_names <- function(ref) { + lapply(ref$authors, function(author) { + author[c("family-names", "given-names")] + }) + } + + data$names <- unique(c( + ref_author_names(x), + unlist(recursive = FALSE, lapply(x$references, ref_author_names)) + )) + + data +} diff --git a/R/linters.R b/R/linters.R index 61a172f..e5f84ff 100644 --- a/R/linters.R +++ b/R/linters.R @@ -14,14 +14,80 @@ #' @name linters NULL +#' Define a lint generic +#' +#' Attach optional attributes to a linting function. Most commonly used for +#' providing attributes: +#' +#' * `name`: A string used for disabling via `# nolint: .` +#' +#' @keywords internal +lint <- function(f, ...) { + structure(f, ..., class = c("lint", class(f))) +} + +#' Find the last called lint function +#' +#' @return `function` the most recent function on the stack of type `lint`. +#' +#' @keywords internal +find_lint_generic <- function(skip = 1) { + if (skip > sys.nframe()) return() + for (i in seq(from = skip, to = sys.nframe())) { + if (inherits(f <- sys.function(which = -i), "lint")) { + return(f) + } + } +} + +#' Check whether the current lint is disabled +#' +#' Scans raw text of tag for `` `r # nolint` or `r # nolint: .` `` +#' +#' @param lint_name `character` name of the lint. Used if only specific lints +#' are disabled for the provided tag. +#' @param fn `function` to use to test for lint disabling. The +#' `"name"` attribute of this function will be used to infer the lint +#' name. Defaults to the most recent `lint` function evaluated on the call +#' stack. +#' +#' @return `logical` flag indicating whether this lint has been disabled for +#' the given tag. +#' +#' @keywords internal +is_lint_disabled <- function( + x, + lint_name = attr(fn, "name"), + fn = find_lint_generic() +) { + if (inherits(x, "roxy_tag")) { + x <- x$raw + } + + disabled_lints <- capture_nolints(x)[[1]] + if (isFALSE(disabled_lints)) { + FALSE + } else if (isTRUE(disabled_lints)) { + # blanket #nolint declaration + TRUE + } else if (lint_name %in% disabled_lints) { + # lint has been explicitly disabled + TRUE + } else { + FALSE + } +} #' @describeIn linters #' Lowercase start linting. (uses `$raw` for [roxygen2::roxy_tag()]s) #' #' @export -lint_starts_lowercase <- function(x, ...) { - UseMethod("lint_sentence_case") -} +lint_starts_lowercase <- lint( + name = "starts_lowercase", + function(x, ...) { + UseMethod("lint_sentence_case") + } +) #' @export lint_starts_lowercase.default <- function(x, ...) { @@ -39,14 +105,16 @@ lint_starts_lowercase.character <- function(x, ...) { message("should not start with an uppercase letter") } - #' @describeIn linters #' Ends in a full stop. (uses `$raw` for [roxygen2::roxy_tag()]s) #' #' @export -lint_full_stop <- function(x, ...) { - UseMethod("lint_full_stop") -} +lint_full_stop <- lint( + name = "full_stop", + function(x, ...) { + UseMethod("lint_full_stop") + } +) #' @export lint_full_stop.default <- function(x, ...) { @@ -54,14 +122,15 @@ lint_full_stop.default <- function(x, ...) { #' @export lint_full_stop.roxy_tag <- function(x, ...) { - lint_full_stop(x$raw, ...) + if (is_lint_disabled(x$raw)) return() + lint_full_stop(lintable_text(x), ...) } #' @export lint_full_stop.character <- function(x, ...) { - re <- "\\.$" + re <- "[.?!]\\s*$" if (!grepl(re, trimws(x))) - message("should terminate with a full stop, `.`") + message("should terminate with a full stop") } @@ -69,9 +138,12 @@ lint_full_stop.character <- function(x, ...) { #' Does not end in a full stop. (uses `$raw` for [roxygen2::roxy_tag()]s) #' #' @export -lint_no_full_stop <- function(x, ...) { - UseMethod("lint_no_full_stop") -} +lint_no_full_stop <- lint( + name = "no_full_stop", + function(x, ...) { + UseMethod("lint_no_full_stop") + } +) #' @export lint_no_full_stop.default <- function(x, ...) { @@ -79,7 +151,8 @@ lint_no_full_stop.default <- function(x, ...) { #' @export lint_no_full_stop.roxy_tag <- function(x, ...) { - lint_no_full_stop(x$raw, ...) + if (is_lint_disabled(x$raw)) return() + lint_no_full_stop(lintable_text(x), ...) } #' @export @@ -94,17 +167,62 @@ lint_no_full_stop.character <- function(x, ...) { #' Sentence case linting (uses `$raw` for [roxygen2::roxy_tag()]s) #' #' @export -lint_sentence_case <- function(x, ...) { - UseMethod("lint_sentence_case") -} +lint_sentence_case <- lint( + name = "sentence_case", + function(x, ...) { + UseMethod("lint_sentence_case") + } +) #' @export lint_sentence_case.default <- function(x, ...) { } #' @export -lint_sentence_case.roxy_tag <- function(x, ...) { - lint_sentence_case(x$raw, ...) +lint_sentence_case.roxy_tag <- function(x, ..., config = list()) { + allow_list <- c(unlist(config$citations$names), config$concepts) + if (is_lint_disabled(x)) return() + + # begin by building a sentence data frame + rdf <- rd_df(tag_rd(x)) + rdf <- rd_df_collapse_text(rdf) + rgdf <- rd_df_split_full_stops(rdf) + rgdf <- lapply(rgdf, rd_df_expand_allow_list, allow_list) + rgdf <- lapply(rgdf, rd_df_expand_words) + + # discover sentences that should be upper-cased + upper_re <- "^[[:upper:]]|[^[:alpha:]]" + lower_re <- "^[[:lower:]]|[^[:alpha:]]" + lints <- unlist(lapply(rgdf, function(sentence_rdf) { + # find first non-ws section of sentence + first_non_ws <- Position(identity, !sentence_rdf$is_whitespace) + if (is.na(first_non_ws)) return() + + # find indices for the rest of the sentence + is_case_text <- with(sentence_rdf, is_text & lintable & !is_whitespace) + rest <- utils::tail(seq(from = first_non_ws, to = nrow(sentence_rdf)), -1L) + rest <- rest[is_case_text[rest]] + + # is text and lintable + first_lintable <- all(sentence_rdf[first_non_ws, c("is_text", "lintable")]) + first_is_upper <- grepl(upper_re, sentence_rdf$content[first_non_ws]) + is_lower <- grepl(lower_re, sentence_rdf$content[rest]) + + c( + if (first_lintable & !first_is_upper) sentence_rdf$content[first_non_ws], + to_lower = sentence_rdf$content[rest][!is_lower] + ) + })) + + if (length(lints) > 0) { + message(paste0( + "should be 'Sentence case'.", + paste0( + "\n Found improperly cased word(s): ", + paste0("'", lints, "'", collapse = ", ") + ) + )) + } } #' @export @@ -129,9 +247,12 @@ lint_sentence_case.character <- function(x, ...) { #' Title case linting #' #' @export -lint_title_case <- function(x, ...) { - UseMethod("lint_title_case") -} +lint_title_case <- lint( + name = "title_case", + function(x, ...) { + UseMethod("lint_title_case") + } +) #' @export lint_title_case.default <- function(x, ...) { @@ -139,13 +260,15 @@ lint_title_case.default <- function(x, ...) { #' @export lint_title_case.roxy_tag <- function(x, ...) { - lint_title_case(x$raw, ...) + if (is_lint_disabled(x$raw)) return() + x <- rd_text(tag_rd(x)) + lint_title_case(x, ...) } #' @export lint_title_case.character <- function(x, ...) { # AP style title case rules - words <- strsplit(x, " ")[[1L]] + words <- strsplit(x, "\\s+")[[1L]] exceptions <- c( "a", "an", "the", # articles "and", "but", "for", # coordinating conjunctions @@ -156,7 +279,7 @@ lint_title_case.character <- function(x, ...) { is_exception[[1]] <- FALSE is_exception[[length(words)]] <- FALSE - if (any(grepl("^[[:lower:]]", words) & !is_exception)) + if (any(grepl("^[[:lower:]]", words[!is_exception]))) message("should be 'Title Case'") } @@ -166,8 +289,8 @@ lint_title_case.character <- function(x, ...) { #' #' @export tidy_title <- function(x, ...) { - lint_sentence_case(x$raw) - lint_no_full_stop(x$raw) + lint_sentence_case(x, ...) + lint_no_full_stop(x, ...) } @@ -175,9 +298,9 @@ tidy_title <- function(x, ...) { #' Tidy 'Sentence case' `@param` definitions #' #' @export -tidy_param <- function(x, name, description, ...) { - lint_sentence_case(description) - lint_full_stop(description) +tidy_param <- function(x, ...) { + lint_sentence_case(x, ...) + lint_full_stop(x, ...) } @@ -186,8 +309,8 @@ tidy_param <- function(x, name, description, ...) { #' #' @export tidy_return <- function(x, ...) { - lint_sentence_case(x$val) - lint_full_stop(x$val) + lint_sentence_case(x, ...) + lint_full_stop(x, ...) } @@ -196,8 +319,8 @@ tidy_return <- function(x, ...) { #' #' @export tidy_seealso <- function(x, ...) { - lint_sentence_case(x$val) - lint_full_stop(x$val) + lint_sentence_case(x, ...) + lint_full_stop(x, ...) } diff --git a/R/rd.R b/R/rd.R new file mode 100644 index 0000000..d5bda14 --- /dev/null +++ b/R/rd.R @@ -0,0 +1,243 @@ +lintable_text <- function(x) { + if (!is.data.frame(x)) x <- rd_df(x) + paste0(x$content[x$is_text & x$lintable], collapse = "") +} + +rd_tag_df <- function(tag, is_text = TRUE, lintable = TRUE) { + content <- paste0(format(tag), collapse = "") + data.frame( + tag = attr(tag, "Rd_tag"), + content = content, + is_text = is_text, + is_whitespace = nchar(trimws(content)) == 0L, + lintable = lintable + ) +} + +rd_tag_text <- function(rd) rd_tag_df(rd) +rd_tag_non_text <- function(rd) rd_tag_df(rd, is_text = FALSE, lintable = FALSE) + +rd_df <- function(rd) { + if (!is.null(tag <- attr(rd, "Rd_tag"))) { + class(rd) <- sub("\\\\", "\\", tag) + } + UseMethod("rd_df", rd) +} + +#' @export +rd_df.roxy_tag <- function(rd) { + rd_df(tag_rd(rd)) +} + +#' @export +rd_df.default <- function(rd) { + if (is.list(rd)) { + do.call(rbind, lapply(rd, function(rd) rd_df(rd))) + } else { + rd_tag_df(rd) + } +} + +#' @export +rd_df.href <- function(rd) { + rd_df(rd[[1]]) +} + +#' @export +rd_df.dfn <- function(rd) { + rd_tag_df(rd, is_text = TRUE, lintable = FALSE) +} + +#' @export +rd_df.itemize <- function(rd) { + is_item_sep <- vapply( + rd, + function(rdi) identical(attr(rdi, "Rd_tag"), "\\item"), + logical(1L) + ) + + # split into distinct items, only separated by naked \\item tag + items <- split(rd[!is_item_sep], cumsum(is_item_sep)[!is_item_sep]) + items <- lapply(items, `class<-`, "item") + + rd_df(items) +} + +#' @export +rd_df.item <- function(rd) { + if (length(rd) == 1) rd_df(rd[[1]]) else rd_df(rd[-1]) +} + +#' @export +rd_df.acronym <- function(rd) { + rd_tag_df(rd, is_text = TRUE, lintable = FALSE) +} + +#' @export +rd_df.preformatted <- rd_tag_non_text + +#' @export +rd_df.code <- rd_tag_non_text + +#' @export +rd_df.pkg <- rd_tag_non_text + +#' @export +rd_df.kbd <- rd_tag_non_text + +#' @export +rd_df.verb <- rd_tag_non_text + +#' @export +rd_df.url <- rd_tag_non_text + +#' @export +rd_df.file <- rd_tag_non_text + +#' @export +rd_df.email <- rd_tag_non_text + +#' @export +rd_df.env <- rd_tag_non_text + +#' @export +rd_df.option <- rd_tag_non_text + +#' @export +rd_df.cite <- rd_tag_non_text + +rd_df_split_full_stops <- local({ + re_full_stop <- paste0( + "(?!<\\bal|\\be\\.g)", # is not exception: "e.g.", "et al." + "[.?!:]", # a stop + "(?:\\s+|$)" # preceding space or end of string + ) + + function(rdf) { + rdf <- rd_df_expand_content_re( + rdf, + re_full_stop, + perl = TRUE, + which = "right" + ) + + rdf$is_whitespace[rdf$is_text] <- grepl("^\\s*$", rdf$content[rdf$is_text]) + + # group by sentence index + sentence_group <- rev(cumsum(rev(rdf$is_match))) + sentence_group <- max(sentence_group) - sentence_group + 1L + + rdf$is_match <- NULL + split(rdf, sentence_group) + } +}) + +rd_df_recalc_whitespace <- function(rdf) { + rdf$is_whitespace[rdf$is_text] <- grepl("^\\s*$", rdf$content[rdf$is_text]) + rdf +} + +rd_df_collapse_text <- function(rdf) { + collapsible <- rdf$tag == "TEXT" & + apply(rdf[, c("is_text", "lintable"), drop = FALSE], 1, all) + group <- cumsum(c(TRUE, head(collapsible, -1) != tail(collapsible, -1))) + rdf <- do.call(rbind, lapply(split(rdf, group), function(rdf) { + if (rdf$tag[[1]] != "TEXT") return(rdf) + rdf$content[[1]] <- paste(rdf$content, collapse = "") + rdf[1, , drop = FALSE] + })) + rdf <- rd_df_recalc_whitespace(rdf) + rdf +} + +rd_df_expand_allow_list <- function(rdf, allowed = NULL) { + if (is.null(allowed)) return(rdf) + + # escape regex, replace any whitespace with arbitrary whitespace regex + patterns <- gsub("\\s+", "\\\\s+", vcapply(allowed, re_escape)) + re <- paste0(patterns, collapse = "|") + + # expand matches and tag them as non-lintable + rdf <- rd_df_expand_content_re(rdf, re) + rdf$lintable <- rdf$lintable & !rdf$is_match + rdf$is_match <- NULL + rdf +} + +rd_df_expand_words <- function(rdf) { + rdf <- rd_df_expand_content_re(rdf, "\\s+") + rdf <- rdf[!rdf$is_match, , drop = FALSE] + rdf$is_match <- NULL + rdf <- rd_df_recalc_whitespace(rdf) + rdf +} + +rd_df_expand_content_re <- function(rdf, re, ..., which = "both") { + m <- gregexec(re, rdf$content, ...) + splits <- split_string_matches(rdf$content, m, which = which) + idx <- rep(seq_along(splits), times = vapply(splits, length, integer(1L))) + rdf <- rdf[idx, ] + rdf$content <- unlist(splits) + rdf$is_match <- switch(which, + "both" = unlist(lapply(splits, seq_along)) %% 2 == 0, + "left" = unlist(lapply(splits, seq_along)) != 1, + "right" = unlist(lapply(splits, function(i) rev(seq_along(i)))) != 1 + ) + rdf +} + +split_string_matches <- function(x, m, which = c("both", "left", "right")) { + which <- match.arg(which) + mapply( + function(x, match) { + if (identical(match[[1]], -1L)) return(x) + m_start <- match + m_len <- attr(match, "match.length") + + cuts <- switch(which, + "both" = as.vector(rbind(m_start, m_start + m_len)), + "left" = m_start, + "right" = m_start + m_len + ) + + substring(x, c(0, cuts), c(cuts - 1, nchar(x))) + }, + x, m, + SIMPLIFY = FALSE, + USE.NAMES = FALSE + ) +} + +split_string_at <- function(x, at) { + mapply( + function(x, at) { + if (identical(at, -1L)) return(x) + substring(x, c(0, at + 1), c(at, nchar(x))) + }, + x, at, + SIMPLIFY = FALSE, + USE.NAMES = FALSE + ) +} + +rd_df_check_sentence_head <- function(rdf) { + # first non-whitespace row + idx <- Position(identity, !rdf$is_whitespace) + if (is.na(idx)) return() + + # check if first token is text, lintable and starts capitalized + is_cap <- rdf$is_text[[idx]] && + rdf$lintable[[idx]] && + grepl("\\s*([[:upper:]]|[^[:alpha:]])", rdf$content[[idx]]) + + if (!is_cap) rdf$content[[idx]] +} + +rd_df_check_sentence_tail <- function(rdf) { + # first non-whitespace row + idx <- Position(identity, !rdf$is_whitespace) + if (is.na(idx)) return() + + rdf <- utils::tail(rdf, -idx) + rdf[rdf$is_text,] +} diff --git a/R/roclet.R b/R/roclet.R index b0a58a7..5c50859 100644 --- a/R/roclet.R +++ b/R/roclet.R @@ -23,12 +23,17 @@ roxylint <- function() { #' @exportS3Method roxygen2::roclet_process roclet_roxylint #' @noRd roclet_process.roclet_roxylint <- function(x, blocks, env, base_path) { # nolint - config <- config_load() + config <- config_load(path = base_path) for (block in blocks) { + # discover @concepts defined in the block & pass to config + concepts <- roxygen2::block_get_tags(block, "concept") + concepts <- unlist(lapply(concepts, `[[`, "val")) + config$concepts <- concepts + for (x in block$tags) { linters <- config$linters[[x$tag]] - check_linter(linters, x) + check_linter(linters, x, config = config) } } diff --git a/R/utils.R b/R/utils.R index 744b1e0..3c5ccfa 100644 --- a/R/utils.R +++ b/R/utils.R @@ -5,6 +5,15 @@ `%||%` <- function(lhs, rhs) if (is.null(lhs)) rhs else lhs +vlapply <- function(..., FUN.VALUE = logical(1L)) { # nolint + vapply(..., FUN.VALUE = FUN.VALUE) +} + +vcapply <- function(..., FUN.VALUE = character(1L)) { # nolint + vapply(..., FUN.VALUE = FUN.VALUE) +} + + #' Get that tag! #' #' Tools for inspecting [roxygen2::roxy_tag()]s. This can be helpful for @@ -23,5 +32,155 @@ demo_tag <- function(str) { str <- strsplit(str, "\n")[[1]] code <- paste0(paste0("#' ", str, collapse = "\n"), "\nNULL") res <- roxygen2::parse_text(code) - res[[1]]$tags[[1]] + roxygen2::roxy_tag_parse(res[[1]]$tags[[1]]) +} + + +#' Capture `# nolint` in strings +#' +#' @param x `character` vector, with strings possibly containing an `r # nolint` +#' declaration. +#' @return A `list` of length equal to that of parameter `x`, giving itemized +#' lint names in cases of matches, `TRUE` if a blanket `# nolint` declaration +#' is provided and `FALSE` if there is no matched `# nolint` declaration at +#' all. +#' +capture_nolints <- local({ + re_nolint <- paste0( + "(`+)", "\\s*", # opening code block (+ possible whitespace) + "r", "\\s*", # r language indicator (+ possible whitespace) + "#", "\\s*", # containing only a comment (+ possible whitespace) + "nolint", "\\s*", # that starts with nolint (+ possible whitespace) + ":?", # colon optional (+ possible whitespace) + "(?.*)", # capture all text until closing backticks + "\\1" + ) + + function(x) { + m <- regexpr(re_nolint, x, perl = TRUE) + nolint_start <- attr(m, "capture.start")[, "nolints"] + nolint_nchar <- attr(m, "capture.length")[, "nolints"] + nolints <- substring(x, nolint_start, nolint_start + nolint_nchar - 1L) + nolints <- gsub("[:.,]", " ", nolints) + out <- lapply(strsplit(nolints, " "), Filter, f = nchar) + out[nolint_start < 0] <- FALSE + out[vapply(out, length, integer(1L)) == 0] <- TRUE + out + } +}) + + + +#' Scrubs a string of texinfo and markdown markup +#' +#' Superceded by `tag_text` but kept around in case the alternatives ever +#' break down for some edge case. +#' +#' @keywords internal +scrub_markup <- local({ + # all patterns expected to place entire match with **second** capture group + # + # extra capture groups are included solely for the sake of making calculations + # easier. + # + markups <- paste( + sep = "|", + # texinfo markup + "()()\\\\(?:code|preformatted){[^{}]*(?R)[^{}]*}", + # markdown bold/italics + "(\\*+)(.*)\\g{-2}", + # markdown code blocks + "(`+)(.*)\\g{-2}", + # markdown-style link syntax + "()\\[.*\\]\\((.*)\\)", + # roxygen2-style link syntax + "()\\[(.*)\\]" + ) + + function(x) { + Map( + function(x, m) { + if (m == -1) return(x) + l <- attr(m, "match.length") + + # match + m_start <- m[1, ] + m_nchar <- l[1, ] + + # replacement + r_start <- apply(m[-1, ][c(FALSE, TRUE), ], 2, sum) + r_nchar <- apply(l[-1, ][c(FALSE, TRUE), ], 2, sum) + + # use a for loop here because this seems to not be vectorized even + # though it seems to indicate it is in the docs? + for (i in rev(seq_along(r_start))) { + substring(x, m_start[[i]], m_start[[i]] + m_nchar[[i]] - 1L) <- + substring(x, r_start[[i]], r_start[[i]] + r_nchar[[i]] - 1L) + } + + x + }, + x, + gregexec(markups, x, perl = TRUE), + USE.NAMES = FALSE + ) + } +}) + +tag_rd <- function(tag) { + rd <- roxygen2::roxy_tag_rd(tag) + if (is.null(rd)) return(tag$raw) + suppressWarnings(tools::parse_Rd( + textConnection(format(rd)), + warningCalls = FALSE, + permissive = TRUE + )) +} + +#' Parse a [`roxygen2`] tag into its visible text +#' +#' Optionally prunes specific `Rd` tags whose rendered content should not +#' affect linting. +#' +#' @param tag A [`roxygen2`] tag. Expected to have an implementation of +#' [`roxygen2::roxy_tag_rd`] and its result should implement [`format`]. +#' @return A `character` string of visible text. +#' +#' @keywords internal +rd_text <- function( + rd, + prune = c( + "\\code", + "\\preformatted", + "\\file", + "\\email", + "\\url", + "\\cite", + "\\acronym" + ) +) { + rd <- prune_rd(rd, prune) + paste(unlist(rd), collapse = "") +} + +#' Prune specific tags from ending up in linted text +#' +#' @keywords internal +prune_rd <- function(rd, prune) { + if (is.list(rd)) { + tag_names <- vapply(rd, attr, character(1L), "Rd_tag") + Filter( + Negate(is.null), + lapply(rd[!tag_names %in% prune], prune_rd, prune = prune) + ) + } else if (isTRUE(attr(rd, "Rd_tag") %in% prune)) { + NULL + } else { + rd + } +} + +#' Borrowed from [`Hmisc`] +re_escape <- function(x) { + gsub("([.|()\\^{}+$*?]|\\[|\\])", "\\\\\\1", x) } diff --git a/man/capture_nolints.Rd b/man/capture_nolints.Rd new file mode 100644 index 0000000..f89d197 --- /dev/null +++ b/man/capture_nolints.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{capture_nolints} +\alias{capture_nolints} +\title{Capture \verb{# nolint} in strings} +\usage{ +capture_nolints(x) +} +\arguments{ +\item{x}{\code{character} vector, with strings possibly containing an +declaration.} +} +\value{ +A \code{list} of length equal to that of parameter \code{x}, giving itemized +lint names in cases of matches, \code{TRUE} if a blanket \verb{# nolint} declaration +is provided and \code{FALSE} if there is no matched \verb{# nolint} declaration at +all. +} +\description{ +Capture \verb{# nolint} in strings +} diff --git a/man/check_linter.Rd b/man/check_linter.Rd index 25d5c8b..4efc89d 100644 --- a/man/check_linter.Rd +++ b/man/check_linter.Rd @@ -6,7 +6,7 @@ \alias{check_linter.list} \alias{check_linter.function} \alias{check_linter.character} -\title{Apply 'roxygen2' linters} +\title{Apply 'roxygen2' Linters} \usage{ check_linter(linters, tag, ...) diff --git a/man/find_lint_generic.Rd b/man/find_lint_generic.Rd new file mode 100644 index 0000000..4124795 --- /dev/null +++ b/man/find_lint_generic.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/linters.R +\name{find_lint_generic} +\alias{find_lint_generic} +\title{Find the last called lint function} +\usage{ +find_lint_generic(skip = 1) +} +\value{ +\code{function} the most recent function on the stack of type \code{lint}. +} +\description{ +Find the last called lint function +} +\keyword{internal} diff --git a/man/is_lint_disabled.Rd b/man/is_lint_disabled.Rd new file mode 100644 index 0000000..ca1063d --- /dev/null +++ b/man/is_lint_disabled.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/linters.R +\name{is_lint_disabled} +\alias{is_lint_disabled} +\title{Check whether the current lint is disabled} +\usage{ +is_lint_disabled(x, lint_name = attr(fn, "name"), fn = find_lint_generic()) +} +\arguments{ +\item{lint_name}{\code{character} name of the lint. Used if only specific lints +are disabled for the provided tag.} + +\item{fn}{\code{function} to use to test for lint disabling. The +\code{"name"} attribute of this function will be used to infer the lint +name. Defaults to the most recent \code{lint} function evaluated on the call +stack.} +} +\value{ +\code{logical} flag indicating whether this lint has been disabled for +the given tag. +} +\description{ +Scans raw text of tag for \verb{`r # nolint` or `r # nolint: .`} +} +\keyword{internal} diff --git a/man/lint.Rd b/man/lint.Rd new file mode 100644 index 0000000..adc40a5 --- /dev/null +++ b/man/lint.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/linters.R +\name{lint} +\alias{lint} +\title{Define a lint generic} +\usage{ +lint(f, ...) +} +\description{ +Attach optional attributes to a linting function. Most commonly used for +providing attributes: +} +\details{ +\itemize{ +\item \code{name}: A string used for disabling via \verb{# nolint: .} +} +} +\keyword{internal} diff --git a/man/linters.Rd b/man/linters.Rd index 4ac29a9..84befd0 100644 --- a/man/linters.Rd +++ b/man/linters.Rd @@ -30,7 +30,7 @@ lint_title_case(x, ...) tidy_title(x, ...) -tidy_param(x, name, description, ...) +tidy_param(x, ...) tidy_return(x, ...) diff --git a/man/macros/nolint.Rd b/man/macros/nolint.Rd new file mode 100644 index 0000000..8c9f463 --- /dev/null +++ b/man/macros/nolint.Rd @@ -0,0 +1 @@ +\newcommand{\nolint}{#1} diff --git a/man/prune_rd.Rd b/man/prune_rd.Rd new file mode 100644 index 0000000..9246624 --- /dev/null +++ b/man/prune_rd.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{prune_rd} +\alias{prune_rd} +\title{Prune specific tags from ending up in linted text} +\usage{ +prune_rd(rd, prune) +} +\description{ +Prune specific tags from ending up in linted text +} +\keyword{internal} diff --git a/man/rd_text.Rd b/man/rd_text.Rd new file mode 100644 index 0000000..4afe7b7 --- /dev/null +++ b/man/rd_text.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{rd_text} +\alias{rd_text} +\title{Parse a \code{\link{roxygen2}} tag into its visible text} +\usage{ +rd_text( + rd, + prune = c("\\\\code", "\\\\preformatted", "\\\\file", "\\\\email", "\\\\url", + "\\\\cite", "\\\\acronym") +) +} +\arguments{ +\item{tag}{A \code{\link{roxygen2}} tag. Expected to have an implementation of +\code{\link[roxygen2:roxy_tag_rd]{roxygen2::roxy_tag_rd}} and its result should implement \code{\link{format}}.} +} +\value{ +A \code{character} string of visible text. +} +\description{ +Optionally prunes specific \code{Rd} tags whose rendered content should not +affect linting. +} +\keyword{internal} diff --git a/man/re_escape.Rd b/man/re_escape.Rd new file mode 100644 index 0000000..f05d7c0 --- /dev/null +++ b/man/re_escape.Rd @@ -0,0 +1,11 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{re_escape} +\alias{re_escape} +\title{Borrowed from \code{\link{Hmisc}}} +\usage{ +re_escape(x) +} +\description{ +Borrowed from \code{\link{Hmisc}} +} diff --git a/man/scrub_markup.Rd b/man/scrub_markup.Rd new file mode 100644 index 0000000..d445e42 --- /dev/null +++ b/man/scrub_markup.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{scrub_markup} +\alias{scrub_markup} +\title{Scrubs a string of texinfo and markdown markup} +\usage{ +scrub_markup(x) +} +\description{ +Superceded by \code{tag_text} but kept around in case the alternatives ever +break down for some edge case. +} +\keyword{internal} diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..a34ffba --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(roxylint) + +test_check("roxylint") diff --git a/tests/testthat/setup-helpers.R b/tests/testthat/setup-helpers.R new file mode 100644 index 0000000..a30baae --- /dev/null +++ b/tests/testthat/setup-helpers.R @@ -0,0 +1,4 @@ +test_tag <- function(..., markdown = TRUE) { + if (markdown) roxygen2:::local_markdown() + roxygen2::roxy_tag_parse(roxygen2::roxy_tag(...)) +} diff --git a/tests/testthat/test-linters.R b/tests/testthat/test-linters.R new file mode 100644 index 0000000..8bdb816 --- /dev/null +++ b/tests/testthat/test-linters.R @@ -0,0 +1,23 @@ +test_that("title_case linter works", { + title_tag <- roxygen2::roxy_tag_parse(roxygen2::roxy_tag( + "title", + "Create a [roxygen2::roxy_tag()] Tag" # ensure links don't trigger lints + )) + + expect_silent( + lint_title_case(title_tag) + ) +}) + +test_that("sentence_case linter works", { + clean_tag <- roxygen2::roxy_tag("clean", "Is sentence case") + expect_silent( + lint_sentence_case(clean_tag) + ) + + linty_tag <- roxygen2::roxy_tag("linty", "Not Sentence Case") + expect_message( + lint_sentence_case(linty_tag), + "[Ss]entence [Cc]ase" + ) +}) diff --git a/tests/testthat/test-nolint.R b/tests/testthat/test-nolint.R new file mode 100644 index 0000000..26a65da --- /dev/null +++ b/tests/testthat/test-nolint.R @@ -0,0 +1,43 @@ +test_that("blanket #nolint declarations suppress all lints", { + blanket_nolint_tag <- test_tag("title", "**Not** [www.test.com](Sentence) Case `r # nolint`") + expect_silent(lint_sentence_case(blanket_nolint_tag)) +}) + +test_that("qualified #nolint declarations suppress only itemized lints", { + qualified_nolint_tag <- test_tag( + "title", + "Not Sentence Case `r # nolint: sentence_case`" + ) + + expect_silent(lint_sentence_case(qualified_nolint_tag)) + + qualified_but_wrong_nolint_tag <- test_tag( + "title", + "Not Sentence Case `r # nolint: title_case`" + ) + + expect_message(lint_sentence_case(qualified_but_wrong_nolint_tag)) +}) + +test_that("qualified #nolint declarations can be puncutated", { + qualified_nolint_tag <- roxygen2::roxy_tag( + "qualified-nolint", + "Not sentence Case Or title Case `r # nolint: sentence_case,title_case.`" + ) + + expect_silent(lint_sentence_case(qualified_nolint_tag)) + expect_silent(lint_title_case(qualified_nolint_tag)) +}) + +test_that("qualified #nolint declarations parsed from multiline code", { + qualified_nolint_tag <- roxygen2::roxy_tag( + "qualified-nolint", + " + Not sentence Case Or title `r # + nolint sentence_case title_case` Case + " + ) + + expect_silent(lint_sentence_case(qualified_nolint_tag)) + expect_silent(lint_title_case(qualified_nolint_tag)) +}) diff --git a/tests/testthat/test-scrub-markup.R b/tests/testthat/test-scrub-markup.R new file mode 100644 index 0000000..d765418 --- /dev/null +++ b/tests/testthat/test-scrub-markup.R @@ -0,0 +1,3 @@ +test_that("scrub_markup catches key markup elements", { + expect_equal(2 * 2, 4) +})