From 11e71c33efe5537ddb511aff28ab4229524bc86e Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:19:10 +0800 Subject: [PATCH 1/7] fix: preserve diagnostic configuration and clear disabled results --- R/diagnostics.R | 39 +++++- R/handlers-workspace.R | 5 +- .../testthat/test-diagnostics-configuration.R | 114 ++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/testthat/test-diagnostics-configuration.R diff --git a/R/diagnostics.R b/R/diagnostics.R index cbadd430..a434d90a 100644 --- a/R/diagnostics.R +++ b/R/diagnostics.R @@ -69,6 +69,35 @@ find_config <- function(filename) { asNamespace("lintr")$find_config(filename) } +#' Lint extracted R code using the settings and exclusions of its source file +#' @noRd +lint_literate_file <- function(path, content, linters, cache) { + # lintr 3.0 interprets text according to the filename's extension, even + # when it has already been extracted. Load the source file's settings + # separately so the line-preserving R content is not extracted again. + lintr_namespace <- asNamespace("lintr") + settings <- lintr_namespace$settings + previous_settings <- as.list(settings, all.names = TRUE) + on.exit({ + rm(list = ls(settings, all.names = TRUE), envir = settings) + list2env(previous_settings, envir = settings) + }) + lintr_namespace$read_settings(path) + lints <- lintr::lint( + text = content, linters = linters, cache = cache, + parse_settings = FALSE + ) + + # Inline linting uses a temporary filename. Restore the real filename + # before applying the config's file and line exclusions. Inline nolint + # comments have already been applied by lint(). + lints[] <- lapply(lints, function(lint) { + lint$filename <- normalizePath(path, mustWork = FALSE) + lint + }) + lintr_namespace$exclude(lints, lines = character()) +} + #' Run diagnostic on a file #' #' Lint and diagnose problems in a file. @@ -107,7 +136,9 @@ diagnose_file <- function(uri, content, is_rmarkdown = FALSE, globals = NULL, ca linters <- lintr::linters_with_defaults() } - if (file.exists(path) && !is_rmarkdown) { + if (nzchar(path) && is_rmarkdown) { + lints <- lint_literate_file(path, content, linters, cache) + } else if (nzchar(path)) { lints <- lintr::lint(path, cache = cache, text = content, @@ -128,9 +159,11 @@ diagnose_file <- function(uri, content, is_rmarkdown = FALSE, globals = NULL, ca diagnostics } -diagnostics_callback <- function(self, uri, version, diagnostics) { +diagnostics_callback <- function(self, uri, version, diagnostics, clear = FALSE) { workspace <- self$get_workspace(uri) - if (is.null(diagnostics) || !workspace$documents$has(uri) || !lsp_settings$get("diagnostics")) return(NULL) + if (is.null(diagnostics) || !workspace$documents$has(uri)) return(NULL) + if (!lsp_settings$get("diagnostics") && !isTRUE(clear)) return(NULL) + if (isTRUE(clear)) diagnostics <- list() document <- workspace$documents$get(uri) if (!is.null(version) && !identical(document$version, version)) { logger$info("diagnostics_callback: discarded stale result", list( diff --git a/R/handlers-workspace.R b/R/handlers-workspace.R index b7376b35..8ddcdb5d 100644 --- a/R/handlers-workspace.R +++ b/R/handlers-workspace.R @@ -77,7 +77,10 @@ workspace_did_change_configuration <- function(self, params) { if (!lsp_settings$get("diagnostics")) { for (workspace in self$workspaces$values()) { for (uri in workspace$documents$keys()) { - diagnostics_callback(self, uri, NULL, list()) + if (!is.null(self$diagnostics_task_manager)) { + self$diagnostics_task_manager$cancel(uri) + } + diagnostics_callback(self, uri, NULL, list(), clear = TRUE) } } } diff --git a/tests/testthat/test-diagnostics-configuration.R b/tests/testthat/test-diagnostics-configuration.R new file mode 100644 index 00000000..058b5854 --- /dev/null +++ b/tests/testthat/test-diagnostics-configuration.R @@ -0,0 +1,114 @@ +test_that("diagnostics use nested configuration for saved and new buffers", { + root <- withr::local_tempdir() + nested <- file.path(root, "nested") + dir.create(nested) + withr::local_dir(root) + writeLines("linters: list()", file.path(root, ".lintr")) + writeLines("linters: list(assignment_linter())", file.path(nested, ".lintr")) + + for (extension in c("R", "Rmd", "qmd")) { + literate <- extension != "R" + content <- if (literate) { + c("# Title", "", "```{r}", "value = 1", "```", + "~~~{r}", "unfinished <- (", "~~~", + "```{python}", "invalid =", "```") + } else { + c("value = 1", "") + } + for (saved in c(TRUE, FALSE)) { + path <- file.path(nested, paste0(if (saved) "saved." else "new.", extension)) + if (saved) writeLines(content, path) + diagnostics <- diagnose_file(path_to_uri(path), content, + is_rmarkdown = literate) + expect_length(diagnostics, 1L) + expect_equal(diagnostics[[1L]]$code, "assignment_linter") + expect_equal(diagnostics[[1L]]$range$start$line, if (literate) 3L else 0L) + expect_equal(file.exists(path), saved) + } + } +}) + +test_that("literate diagnostics preserve custom markers and config exclusions", { + root <- withr::local_tempdir() + withr::local_dir(root) + content <- c("# Title", "```{r}", "first = 1", "second = 2 # quiet", + "third = 3", "```") + writeLines(c( + "linters: list(assignment_linter())", + "exclude: '# quiet'", + "exclusions: list('source.Rmd' = 3L, 'source.qmd' = 3L)" + ), file.path(root, ".lintr")) + + for (extension in c("Rmd", "qmd")) { + path <- file.path(root, paste0("source.", extension)) + writeLines(content, path) + diagnostics <- diagnose_file(path_to_uri(path), content, is_rmarkdown = TRUE) + expect_length(diagnostics, 1L) + expect_equal(diagnostics[[1L]]$code, "assignment_linter") + expect_equal(diagnostics[[1L]]$range$start$line, 4L) + } + + writeLines(c("linters: list(assignment_linter())", + "exclusions: list('source.Rmd', 'source.qmd')"), file.path(root, ".lintr")) + for (extension in c("Rmd", "qmd")) { + path <- file.path(root, paste0("source.", extension)) + expect_length(diagnose_file(path_to_uri(path), content, + is_rmarkdown = TRUE), 0L) + } +}) + +test_that("literate diagnostics restore lintr settings after errors", { + root <- withr::local_tempdir() + config <- file.path(root, ".lintr") + path <- file.path(root, "source.Rmd") + settings <- asNamespace("lintr")$settings + setting_values <- function() mget(sort(ls(settings, all.names = TRUE)), settings) + before <- setting_values() + writeLines("linters: list(assignment_linter())", config) + diagnose_file(path_to_uri(path), c("```{r}", "value = 1", "```"), + is_rmarkdown = TRUE) + expect_equal(setting_values(), before) + + writeLines("linters: list(", config) + expect_error(diagnose_file(path_to_uri(path), + c("```{r}", "value = 1", "```"), is_rmarkdown = TRUE)) + expect_equal(setting_values(), before) +}) + +test_that("disabling diagnostics clears documents and cancels pending work", { + old_diagnostics <- lsp_settings$get("diagnostics") + withr::defer(lsp_settings$set("diagnostics", old_diagnostics)) + withr::local_options(languageserver.diagnostics = NULL) + lsp_settings$set("diagnostics", TRUE) + uri <- "file:///disable-diagnostics.R" + workspace <- list(documents = collections::dict()) + workspace$documents$set(uri, Document$new(uri, version = 1L, content = "x = 1")) + self <- new.env(parent = baseenv()) + self$get_workspace <- function(...) workspace + self$workspaces <- collections::dict() + self$workspaces$set("root", workspace) + self$deliveries <- list() + self$deliver <- function(message) { + self$deliveries[[length(self$deliveries) + 1L]] <- message + } + self$diagnostics_task_manager <- TaskManager$new("diagnostics") + withr::defer(self$diagnostics_task_manager$stop()) + warning <- list(list(message = "existing warning")) + diagnostics_callback(self, uri, 1L, warning) + self$diagnostics_task_manager$add_task(uri, create_task(function() NULL, list())) + expect_true(self$diagnostics_task_manager$has_work()) + + workspace_did_change_configuration(self, list(settings = list(diagnostics = FALSE))) + expect_length(self$deliveries, 2L) + expect_identical(self$deliveries[[2L]]$params$diagnostics, list()) + expect_equal(self$deliveries[[2L]]$params$uri, uri) + expect_false(self$diagnostics_task_manager$has_work()) + + diagnostics_callback(self, uri, 1L, warning) + diagnostics_callback(self, uri, 1L, list()) + expect_length(self$deliveries, 2L) + workspace_did_change_configuration(self, list(settings = list(diagnostics = TRUE))) + diagnostics_callback(self, uri, 1L, warning) + expect_length(self$deliveries, 3L) + expect_identical(self$deliveries[[3L]]$params$diagnostics, warning) +}) From 8b8fcbada3c0cbce89d33ab19b665786e69872f8 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:19:10 +0800 Subject: [PATCH 2/7] feat: run explicit formatting in cancellable background workers --- R/formatting.R | 95 ++++++++ R/handlers-general.R | 1 + R/handlers-langfeatures.R | 7 +- R/handlers-textsync.R | 10 + R/languageserver.R | 11 + tests/testthat/test-formatting-background.R | 252 ++++++++++++++++++++ tests/testthat/test-handlers-langfeatures.R | 3 - 7 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 tests/testthat/test-formatting-background.R diff --git a/R/formatting.R b/R/formatting.R index eeaed886..69bae100 100644 --- a/R/formatting.R +++ b/R/formatting.R @@ -1,3 +1,98 @@ +#' Format an immutable document snapshot in a background worker +#' @noRd +format_document <- function(snapshot, options, range = NULL, ranges = NULL, + formatting_options = list(), working_directory = getwd()) { + old_directory <- setwd(working_directory) + on.exit(setwd(old_directory), add = TRUE) + old_options <- base::options(formatting_options) + on.exit(base::options(old_options), add = TRUE) + + document <- Document$new(snapshot$uri, language = snapshot$language, + version = snapshot$version, content = snapshot$content) + reply <- if (!is.null(ranges)) { + ranges_formatting_reply(NULL, snapshot$uri, document, ranges, options) + } else if (!is.null(range)) { + range_formatting_reply(NULL, snapshot$uri, document, range, options) + } else { + formatting_reply(NULL, snapshot$uri, document, options) + } + reply$result +} + +#' Complete formatting only while its document snapshot is still current +#' @noRd +formatting_callback <- function(self, request, result = NULL, error = NULL) { + key <- as.character(request$id) + if (!identical(self$formatting_requests$get(key, NULL), request)) { + return(invisible(NULL)) + } + self$formatting_requests$remove(key) + workspace <- self$get_workspace(request$uri) + current <- workspace$documents$get(request$uri, NULL) + if (is.null(current) || !identical(current, request$document) || + !identical(current$version, request$version) || + !identical(current$content, request$content) || + !identical(current$is_open, request$is_open)) { + reply <- ResponseErrorMessage$new(request$id, "RequestCancelled", + "Request superseded by updated document content") + } else if (!is.null(error)) { + logger$info("formatting task error: ", error) + reply <- ResponseErrorMessage$new(request$id, "InternalError", + "Background formatting failed") + } else { + reply <- Response$new(request$id, result) + } + self$deliver(reply) + invisible(NULL) +} + +#' Queue full-document and explicit-selection formatting without blocking input +#' @noRd +enqueue_formatting <- function(self, id, uri, document, options, + range = NULL, ranges = NULL) { + request <- list(id = id, uri = uri, version = document$version, + content = document$content, is_open = document$is_open, + document = document, token = new.env(parent = emptyenv())) + snapshot <- list(uri = uri, language = document$language, + version = document$version, content = document$content) + formatting_options <- base::options() + formatting_options <- formatting_options[ + grepl("^styler\\.", names(formatting_options))] + # Explicitly include NULL so a worker's startup profile cannot override + # the formatter selected in the language-server process. + formatting_options["languageserver.formatting_style"] <- list( + getOption("languageserver.formatting_style")) + task <- create_task( + target = package_call(format_document), + args = list(snapshot = snapshot, options = options, range = range, + ranges = ranges, formatting_options = formatting_options, + working_directory = getwd()), + callback = function(result) formatting_callback(self, request, result), + error = function(error) formatting_callback(self, request, error = error) + ) + key <- as.character(id) + self$formatting_requests$set(key, request) + self$formatting_task_manager$add_task(key, task) + invisible(NULL) +} + +#' Cancel pending or running formatting and send exactly one response +#' @noRd +cancel_formatting_requests <- function(self, uri = NULL, id = NULL, + message = "Request cancelled by client") { + if (is.null(self$formatting_requests)) return(invisible(NULL)) + for (key in self$formatting_requests$keys()) { + request <- self$formatting_requests$get(key) + if ((!is.null(uri) && !identical(request$uri, uri)) || + (!is.null(id) && !identical(key, as.character(id)))) next + self$formatting_requests$remove(key) + self$formatting_task_manager$cancel(key) + self$deliver(ResponseErrorMessage$new( + request$id, "RequestCancelled", message)) + } + invisible(NULL) +} + get_style <- function(options) { style <- getOption("languageserver.formatting_style") if (is.null(style)) { diff --git a/R/handlers-general.R b/R/handlers-general.R index f2892511..79f4679e 100644 --- a/R/handlers-general.R +++ b/R/handlers-general.R @@ -89,6 +89,7 @@ on_exit <- function(self, params) { #' @noRd cancel_request <- function(self, params) { request_id <- params$id + if (!is.null(request_id)) cancel_formatting_requests(self, id = request_id) for (uri in self$pending_replies$keys()) { queues <- self$pending_replies$get(uri) for (queue in queues) { diff --git a/R/handlers-langfeatures.R b/R/handlers-langfeatures.R index b6092368..abe45dbc 100644 --- a/R/handlers-langfeatures.R +++ b/R/handlers-langfeatures.R @@ -311,7 +311,7 @@ text_document_formatting <- function(self, id, params) { document <- workspace$documents$get(uri) if (is.null(document)) return(self$deliver(Response$new(id = id, result = NULL))) options <- params$options - self$deliver(formatting_reply(id, uri, document, options)) + enqueue_formatting(self, id, uri, document, options) } #' `textDocument/rangeFormatting` request handler @@ -329,7 +329,7 @@ text_document_range_formatting <- function(self, id, params) { end = document$from_lsp_position(params$range$end) ) options <- params$options - self$deliver(range_formatting_reply(id, uri, document, range, options)) + enqueue_formatting(self, id, uri, document, options, range = range) } #' `textDocument/rangesFormatting` request handler (LSP 3.18) @@ -346,8 +346,7 @@ text_document_ranges_formatting <- function(self, id, params) { end = document$from_lsp_position(item$end) ) }) - self$deliver(ranges_formatting_reply( - id, uri, document, ranges, params$options)) + enqueue_formatting(self, id, uri, document, params$options, ranges = ranges) } diff --git a/R/handlers-textsync.R b/R/handlers-textsync.R index e64549ff..b16a6f47 100644 --- a/R/handlers-textsync.R +++ b/R/handlers-textsync.R @@ -15,6 +15,8 @@ update_document_index <- function(self, workspace, uri, content, text_document_did_open <- function(self, params) { textDocument <- params$textDocument uri <- uri_escape_unicode(textDocument$uri) + cancel_formatting_requests(self, uri = uri, + message = "Request superseded by a reopened document") language <- textDocument$languageId version <- textDocument$version text <- textDocument$text @@ -51,6 +53,8 @@ text_document_did_change <- function(self, params) { uri <- uri_escape_unicode(textDocument$uri) version <- textDocument$version logger$info("did change:", list(uri = uri, version = version)) + cancel_formatting_requests(self, uri = uri, + message = "Request superseded by a newer document version") pending <- self$pending_replies$get(uri, NULL) for (queue in pending) { @@ -135,6 +139,10 @@ text_document_did_save <- function(self, params) { content <- NULL } doc <- workspace$documents$get(uri) + if (!identical(doc$content, content)) { + cancel_formatting_requests(self, uri = uri, + message = "Request superseded by updated document content") + } doc$set_content(doc$version, content) doc$did_open() update_document_index( @@ -149,6 +157,8 @@ text_document_did_save <- function(self, params) { text_document_did_close <- function(self, params) { textDocument <- params$textDocument uri <- uri_escape_unicode(textDocument$uri) + cancel_formatting_requests(self, uri = uri, + message = "Request cancelled because the document was closed") path <- path_from_uri(uri) workspace <- self$get_workspace(uri) diff --git a/R/languageserver.R b/R/languageserver.R index 04f397d5..d94d1e8f 100644 --- a/R/languageserver.R +++ b/R/languageserver.R @@ -37,6 +37,8 @@ LanguageServer <- R6::R6Class("LanguageServer", diagnostics_task_manager = NULL, parse_task_manager = NULL, resolve_task_manager = NULL, + formatting_task_manager = NULL, + formatting_requests = NULL, pending_replies = NULL, initialize = function(host, port) { if (is.null(port)) { @@ -72,6 +74,12 @@ LanguageServer <- R6::R6Class("LanguageServer", # resolve task require a new session for every task self$resolve_task_manager <- TaskManager$new("resolve") + self$formatting_task_manager <- TaskManager$new( + "formatting", use_session = TRUE, + max_running_tasks = 1, min_idle_sessions = 0 + ) + self$formatting_requests <- collections::dict() + self$pending_replies <- collections::dict() self$workspaces <- collections::dict() self$workspace_cache <- collections::dict() @@ -83,6 +91,7 @@ LanguageServer <- R6::R6Class("LanguageServer", self$parse_task_manager$check_tasks() self$diagnostics_task_manager$check_tasks() self$resolve_task_manager$check_tasks() + self$formatting_task_manager$check_tasks() # Start latency-sensitive parse work before diagnostics. self$parse_task_manager$run_tasks() if (!self$parse_task_manager$has_work()) { @@ -95,6 +104,7 @@ LanguageServer <- R6::R6Class("LanguageServer", self$diagnostics_task_manager$run_tasks() } self$resolve_task_manager$run_tasks() + self$formatting_task_manager$run_tasks() for (workspace in self$workspaces$values()) { workspace$poll_namespace_file() } @@ -400,6 +410,7 @@ LanguageServer <- R6::R6Class("LanguageServer", if (!is.null(self$parse_task_manager)) self$parse_task_manager$stop() if (!is.null(self$diagnostics_task_manager)) self$diagnostics_task_manager$stop() if (!is.null(self$resolve_task_manager)) self$resolve_task_manager$stop() + if (!is.null(self$formatting_task_manager)) self$formatting_task_manager$stop() }, add = TRUE ) diff --git a/tests/testthat/test-formatting-background.R b/tests/testthat/test-formatting-background.R new file mode 100644 index 00000000..777c5237 --- /dev/null +++ b/tests/testthat/test-formatting-background.R @@ -0,0 +1,252 @@ +formatting_fixture <- function(content = "x=1", uri = "file:///formatting.R") { + self <- new.env(parent = baseenv()) + document <- Document$new(uri, version = 1L, content = content) + document$did_open() + documents <- collections::dict() + documents$set(uri, document) + workspace <- new.env(parent = baseenv()) + workspace$documents <- documents + workspace$root <- tempdir() + workspace$type_hierarchy_cache <- collections::dict() + workspace$update_loaded_packages <- function() NULL + self$get_workspace <- function(...) workspace + self$text_sync <- function(...) NULL + self$deliveries <- list() + self$deliver <- function(reply) { + self$deliveries[[length(self$deliveries) + 1L]] <- reply + } + self$pending_replies <- collections::dict() + self$pending_replies$set(uri, list()) + self$formatting_requests <- collections::dict() + self$formatting_task_manager <- TaskManager$new("formatting-test", + use_session = TRUE, min_idle_sessions = 0, max_running_tasks = 1) + withr::defer_parent(self$formatting_task_manager$stop()) + list(self = self, document = document, uri = uri, + manager = self$formatting_task_manager, + params = list(textDocument = list(uri = uri), + options = list(tabSize = 2L, insertSpaces = TRUE))) +} + +formatting_test_task <- function(fixture, id) { + fixture$manager$.__enclos_env__$private$pending_tasks$get(as.character(id)) +} + +test_that("explicit formatting handlers enqueue immutable snapshots", { + fixture <- formatting_fixture(c("x=1", "y=2")) + fixture$params$range <- list(start = position(0L, 0L), end = position(0L, 3L)) + fixture$params$ranges <- list(fixture$params$range) + handlers <- list(text_document_formatting, text_document_range_formatting, + text_document_ranges_formatting) + for (id in seq_along(handlers)) { + handlers[[id]](fixture$self, id, fixture$params) + } + expect_length(fixture$self$deliveries, 0L) + expect_equal(fixture$self$formatting_requests$size(), 3L) + fixture$document$set_content(2L, "new=3") + for (id in seq_along(handlers)) { + args <- formatting_test_task(fixture, id)$.__enclos_env__$private$args + expect_equal(args$snapshot$content, c("x=1", "y=2")) + expect_equal(args$snapshot$version, 1L) + expect_equal(is.null(args$range), id != 2L) + expect_equal(is.null(args$ranges), id != 3L) + } +}) + +test_that("formatting rejects changed, closed, removed and reopened documents", { + for (change in c("version", "content", "closed", "removed", "reopened")) { + fixture <- formatting_fixture() + text_document_formatting(fixture$self, 1L, fixture$params) + task <- formatting_test_task(fixture, 1L) + documents <- fixture$self$get_workspace()$documents + switch(change, + version = fixture$document$version <- 2L, + content = fixture$document$set_content(1L, "different=2"), + closed = fixture$document$did_close(), + removed = documents$remove(fixture$uri), + reopened = documents$set(fixture$uri, + Document$new(fixture$uri, version = 1L, content = "x=1")) + ) + task$.__enclos_env__$private$callback(list(list(newText = "x <- 1"))) + expect_equal(fixture$self$deliveries[[1L]]$error$code, -32800L) + expect_equal(fixture$self$formatting_requests$size(), 0L) + } +}) + +test_that("formatting cancellation answers once and leaves other requests queued", { + fixture <- formatting_fixture() + text_document_formatting(fixture$self, 1L, fixture$params) + text_document_formatting(fixture$self, 2L, fixture$params) + task <- formatting_test_task(fixture, 1L) + cancel_request(fixture$self, list(id = "1")) + expect_length(fixture$self$deliveries, 1L) + expect_equal(fixture$self$deliveries[[1L]]$id, 1L) + expect_equal(fixture$self$deliveries[[1L]]$error$code, -32800L) + expect_equal(fixture$self$formatting_requests$size(), 1L) + task$.__enclos_env__$private$callback(list(list(newText = "stale"))) + expect_length(fixture$self$deliveries, 1L) + cancel_formatting_requests(fixture$self, uri = "file:///another.R") + expect_length(fixture$self$deliveries, 1L) + cancel_formatting_requests(fixture$self, uri = fixture$uri) + expect_length(fixture$self$deliveries, 2L) + expect_false(fixture$manager$has_work()) +}) + +test_that("formatting worker errors complete and remove their requests", { + fixture <- formatting_fixture() + text_document_formatting(fixture$self, 1L, fixture$params) + formatting_test_task(fixture, 1L)$fail(simpleError("formatter failed")) + expect_equal(fixture$self$deliveries[[1L]]$error$code, -32603L) + expect_equal(fixture$self$formatting_requests$size(), 0L) +}) + +test_that("document lifecycle notifications cancel obsolete formatting work", { + for (event in c("change", "reopen", "close")) { + fixture <- formatting_fixture() + text_document_formatting(fixture$self, 1L, fixture$params) + params <- list(textDocument = list(uri = fixture$uri, + version = 2L, languageId = "r", text = "x=2"), + contentChanges = list(list(text = "x=2"))) + switch(event, + change = text_document_did_change(fixture$self, params), + reopen = text_document_did_open(fixture$self, params), + close = text_document_did_close(fixture$self, params) + ) + replies <- Filter(function(reply) !is.null(reply$id), fixture$self$deliveries) + expect_length(replies, 1L) + expect_equal(replies[[1L]]$error$code, -32800L) + expect_false(fixture$manager$has_work()) + } +}) + +test_that("saving identical content preserves formatting but disk changes cancel it", { + path <- withr::local_tempfile(fileext = ".R") + writeLines("x=1", path) + fixture <- formatting_fixture(uri = path_to_uri(path)) + text_document_formatting(fixture$self, 1L, fixture$params) + text_document_did_save(fixture$self, fixture$params) + expect_length(fixture$self$deliveries, 0L) + expect_true(fixture$manager$has_work()) + writeLines("x=2", path) + text_document_did_save(fixture$self, fixture$params) + expect_length(fixture$self$deliveries, 1L) + expect_equal(fixture$self$deliveries[[1L]]$error$code, -32800L) + expect_false(fixture$manager$has_work()) +}) + +test_that("background formatting preserves custom styles and literate boundaries", { + skip_on_cran() + withr::local_options(languageserver.formatting_style = function(options) { + style <- styler::tidyverse_style(indent_by = options$tabSize) + style$token$force_assignment_op <- NULL + style + }) + fixture <- formatting_fixture( + c("Prose", "```{r}", "x=1", "```", "```{python}", "x=1", "```"), + "file:///formatting.qmd") + text_document_formatting(fixture$self, 1L, fixture$params) + deadline <- Sys.time() + 15 + while (fixture$manager$has_work() && Sys.time() < deadline) { + fixture$manager$run_tasks() + fixture$manager$check_tasks() + Sys.sleep(0.01) + } + expect_length(fixture$self$deliveries, 1L) + reply <- fixture$self$deliveries[[1L]] + expect_null(reply$error) + expect_length(reply$result, 1L) + expect_equal(reply$result[[1L]]$newText, "x = 1") + expect_equal(reply$result[[1L]]$range$start$line, 2L) + expect_equal(reply$result[[1L]]$range$end$line, 2L) + + # Reusing the same session must pick up a changed server-side option. + options(languageserver.formatting_style = NULL) + text_document_formatting(fixture$self, 2L, fixture$params) + deadline <- Sys.time() + 15 + while (fixture$manager$has_work() && Sys.time() < deadline) { + fixture$manager$run_tasks() + fixture$manager$check_tasks() + Sys.sleep(0.01) + } + expect_length(fixture$self$deliveries, 2L) + expect_equal(fixture$self$deliveries[[2L]]$result[[1L]]$newText, "x <- 1") + + fixture$params$range <- list(start = position(2L, 0L), end = position(2L, 3L)) + fixture$params$ranges <- list(fixture$params$range, fixture$params$range) + text_document_range_formatting(fixture$self, 3L, fixture$params) + text_document_ranges_formatting(fixture$self, 4L, fixture$params) + deadline <- Sys.time() + 15 + while (fixture$manager$has_work() && Sys.time() < deadline) { + fixture$manager$run_tasks() + fixture$manager$check_tasks() + Sys.sleep(0.01) + } + expect_length(fixture$self$deliveries, 4L) + for (id in 3:4) { + reply <- fixture$self$deliveries[[id]] + expect_null(reply$error) + expect_length(reply$result, 1L) + expect_equal(reply$result[[1L]]$newText, "x <- 1") + expect_equal(reply$result[[1L]]$range$start$line, 2L) + expect_equal(reply$result[[1L]]$range$end$line, 2L) + } +}) + +test_that("a running formatter leaves input responsive and can be cancelled", { + skip_on_cran() + # Coverage collection deliberately waits for workers instead of killing + # them; this test specifically exercises prompt process cancellation. + skip_if(identical(Sys.getenv("R_COVR"), "true")) + marker <- withr::local_tempfile() + script <- sprintf(paste0( + "options(languageserver.diagnostics = FALSE, ", + "languageserver.formatting_style = function(options) { ", + "if (isTRUE(options$testDelay)) { writeLines('started', %s); Sys.sleep(30) }; ", + "styler::tidyverse_style(indent_by = options$tabSize) }); ", + "languageserver::run()"), encodeString(marker, quote = '"')) + client <- LanguageClient$new(file.path(R.home("bin"), "R"), + c("--no-echo", "-e", script)) + withr::defer({ + if (client$process$is_alive()) { + client$deliver(client$request("shutdown", NULL)) + client$process$wait(1000) + if (client$process$is_alive()) client$process$kill() + } + }) + client$catch_callback_error <- FALSE + client$start(working_dir = NULL) + client$handle_raw(client$fetch(blocking = TRUE, timeout = 10)) + notify(client, "initialized") + uri <- path_to_uri(withr::local_tempfile(fileext = ".R")) + did_open(client, uri = uri, text = "f(x+1)") + params <- list(textDocument = list(uri = uri), + options = list(tabSize = 2L, insertSpaces = TRUE, testDelay = TRUE)) + replies <- list() + request <- client$request("textDocument/formatting", params) + client$deliver(request, callback = function(self, result, error = NULL) { + replies[[length(replies) + 1L]] <<- list(result = result, error = error) + }) + deadline <- Sys.time() + 10 + while (!file.exists(marker) && Sys.time() < deadline) Sys.sleep(0.01) + expect_true(file.exists(marker)) + + # On-type formatting uses its own synchronous path and must not wait for + # the explicit-formatting worker to finish its 30-second operation. + result <- respond_on_type_formatting(client, uri = uri, + pos = c(0L, 6L), ch = ")", timeout = 5, retry = FALSE) + expect_length(result, 1L) + expect_equal(result[[1L]]$newText, "f(x + 1)") + expect_length(replies, 0L) + notify(client, "$/cancelRequest", list(id = request$id)) + deadline <- Sys.time() + 5 + while (!length(replies) && Sys.time() < deadline) { + data <- client$fetch(blocking = TRUE, timeout = 1) + if (!is.null(data)) client$handle_raw(data) + } + expect_length(replies, 1L) + expect_equal(replies[[1L]]$error$code, -32800L) + + # A fresh task must still work after the cancelled session was retired. + result <- respond_formatting(client, uri = uri, timeout = 10, retry = FALSE) + expect_equal(result[[1L]]$newText, "f(x + 1)\n") + expect_length(replies, 1L) +}) diff --git a/tests/testthat/test-handlers-langfeatures.R b/tests/testthat/test-handlers-langfeatures.R index b451a8a9..15e76760 100644 --- a/tests/testthat/test-handlers-langfeatures.R +++ b/tests/testthat/test-handlers-langfeatures.R @@ -60,9 +60,6 @@ test_that("language feature handlers deliver successful provider replies", { list(text_document_document_link, "document_link_reply"), list(text_document_document_color, "document_color_reply"), list(text_document_color_presentation, "color_presentation_reply"), - list(text_document_formatting, "formatting_reply"), - list(text_document_range_formatting, "range_formatting_reply"), - list(text_document_ranges_formatting, "ranges_formatting_reply"), list(text_document_on_type_formatting, "on_type_formatting_reply"), list(text_document_rename, "rename_reply"), list(text_document_prepare_rename, "prepare_rename_reply"), From c2eb6fc86119659afb88ab80def5441f8b37e37d Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:19:10 +0800 Subject: [PATCH 3/7] ci: check older and development R with weekly native analysis --- .github/workflows/rcmdcheck.yml | 18 +++++++++++++++--- .github/workflows/rhub.yaml | 21 +++++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/rcmdcheck.yml b/.github/workflows/rcmdcheck.yml index 7ad21da6..ca74fcdf 100644 --- a/.github/workflows/rcmdcheck.yml +++ b/.github/workflows/rcmdcheck.yml @@ -1,5 +1,8 @@ name: R-CMD-check +permissions: + contents: read + on: push: branches: @@ -15,17 +18,26 @@ concurrency: jobs: rcmdcheck: if: contains(github.event.head_commit.message, '[ci skip]') == false - name: rcmdcheck ${{ matrix.os }} + name: rcmdcheck ${{ matrix.os }} (R ${{ matrix.r }}) strategy: fail-fast: false matrix: include: - os: ubuntu-latest + r: release log_file: /tmp/languageserver/ubuntu-log - os: macos-latest + r: release log_file: /tmp/languageserver/macos-log - os: windows-latest + r: release log_file: C:/tmp/languageserver/windows-log + - os: ubuntu-latest + r: oldrel-1 + log_file: /tmp/languageserver/oldrel-log + - os: ubuntu-latest + r: devel + log_file: /tmp/languageserver/devel-log runs-on: ${{ matrix.os }} env: NOT_CRAN: true @@ -36,7 +48,7 @@ jobs: - uses: actions/checkout@v7 - uses: r-lib/actions/setup-r@v2 with: - r-version: release + r-version: ${{ matrix.r }} - name: Create log directory on Linux or macOS if: runner.os != 'Windows' run: mkdir -p $(dirname ${{ env.R_LANGSVR_LOG }}) @@ -77,5 +89,5 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: ${{ runner.os }}-log + name: ${{ runner.os }}-${{ matrix.r }}-log path: ${{ env.R_LANGSVR_LOG }} diff --git a/.github/workflows/rhub.yaml b/.github/workflows/rhub.yaml index 74ec7b05..0f737fd4 100644 --- a/.github/workflows/rhub.yaml +++ b/.github/workflows/rhub.yaml @@ -7,9 +7,18 @@ # It is unlikely that you need to modify this file manually. name: R-hub -run-name: "${{ github.event.inputs.id }}: ${{ github.event.inputs.name || format('Manually run by {0}', github.triggering_actor) }}" +run-name: "${{ github.event_name == 'schedule' && 'Weekly native memory checks' || github.event.inputs.name || format('Manually run by {0}', github.triggering_actor) }}" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: false on: + schedule: + - cron: '23 3 * * 1' workflow_dispatch: inputs: config: @@ -32,11 +41,11 @@ jobs: platforms: ${{ steps.rhub-setup.outputs.platforms }} steps: - # NO NEED TO CHECKOUT HERE - - uses: r-hub/actions/setup@v1 - with: - config: ${{ github.event.inputs.config }} - id: rhub-setup + # No checkout needed to resolve the platform matrix. + - uses: r-hub/actions/setup@v1 + with: + config: ${{ github.event.inputs.config || 'clang-asan,valgrind,rchk' }} + id: rhub-setup linux-containers: needs: setup From 4fa8d6c1832b21d06628a83449d2f84540ae0335 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:19:10 +0800 Subject: [PATCH 4/7] perf: measure cold startup and diagnostics during typing --- inst/benchmarks/README.md | 27 +++++++++++++++ inst/benchmarks/typing.py | 71 +++++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/inst/benchmarks/README.md b/inst/benchmarks/README.md index 18aa1227..dc1a3712 100644 --- a/inst/benchmarks/README.md +++ b/inst/benchmarks/README.md @@ -144,6 +144,33 @@ Omit `--wait-parse` to type immediately after opening; `--open-delay`, `--pause` and `--rounds` control other typing scenarios. `--profile /tmp/typing.Rprof` records an R sampling profile without enabling server debug logging. +### Cold startup and diagnostics + +Each invocation starts a fresh R process. The `startup.initialize_ms` field +measures process launch through the initialize response, and the first +completion includes `startup_to_first_completion_ms`. With `--wait-parse`, the +first preparation record also reports `open_to_symbols_ms`. These wall-clock +measurements include any requested pauses, parse waits, or diagnostic waits; +the per-request `elapsed_ms` remains the interactive completion measurement. + +```sh +# Begin typing immediately after opening, without waiting for the initial parse. +python3 inst/benchmarks/typing.py /tmp/languageserver-after --lines 20000 --index auto --pause 0 + +# Exercise the normal diagnostics workload and verify the final edited version. +python3 inst/benchmarks/typing.py /tmp/languageserver-after --lines 5000 --index auto --providers --diagnostics --wait-diagnostics --pause 1 --rounds 2 +``` + +`--diagnostics` enables lintr with a fixture-local default configuration and +disables its result cache. `--wait-diagnostics` requires an initial publication +before typing and a publication for the final document version before shutdown. +The transport summary includes publication counts and versions, so a run that +never reaches diagnostics is distinguishable from one exercising the worker. +The one-second typing interval allows the normal diagnostics debounce to fire; +use `--pause 0.25` separately to measure continuous typing and cancellation. +Diagnostics failures and missing final publications make the run fail. Run +comparisons sequentially, with the same flags and R environment on each revision. + Regression coverage checks old/new result equivalence, Unicode and UTF-16, missing arguments, nested/overlapping scopes, cursor boundaries, literate and incomplete documents, serialization, edits, live settings/files, namespace diff --git a/inst/benchmarks/typing.py b/inst/benchmarks/typing.py index b7b2bc43..58aacf46 100644 --- a/inst/benchmarks/typing.py +++ b/inst/benchmarks/typing.py @@ -14,6 +14,10 @@ parser.add_argument("library") parser.add_argument("--lines", type=int, default=20000) parser.add_argument("--providers", action="store_true") +parser.add_argument("--diagnostics", action="store_true", + help="enable lintr while measuring typing and report diagnostic publications") +parser.add_argument("--wait-diagnostics", action="store_true", + help="wait for diagnostics before typing and for the final edited version") parser.add_argument("--wait-parse", action="store_true") parser.add_argument("--index", choices=("off", "auto"), default="off") parser.add_argument("--rounds", type=int, default=1) @@ -24,20 +28,26 @@ args = parser.parse_args() if args.lines < 1 or args.rounds < 1 or min(args.pause, args.open_delay) < 0: parser.error("lines/rounds must be positive and delays nonnegative") +if args.wait_diagnostics and not args.diagnostics: + parser.error("--wait-diagnostics requires --diagnostics") root = Path(tempfile.mkdtemp(prefix="completion-fixture-")) file = root / "script.R" lines = [f"value_{i:05d} <- sum(c(1, 2, 3))" for i in range(args.lines)] + [""] file.write_text("\n".join(lines), encoding="utf-8") +# Keep the diagnostic workload independent of the user's home .lintr file. +(root / ".lintr").write_text("linters: lintr::linters_with_defaults()\n", encoding="utf-8") env = os.environ.copy() env["R_LIBS"] = os.pathsep.join(filter(None, (str(Path(args.library).resolve()), env.get("R_LIBS", "")))) -program = ('options(languageserver.diagnostics=FALSE, ' +program = (f'options(languageserver.diagnostics={"TRUE" if args.diagnostics else "FALSE"}, ' + 'languageserver.lint_cache=FALSE, ' 'languageserver.index_persistent_cache=FALSE, ' f'languageserver.index_mode="{args.index}"); library(languageserver); ') if args.profile: program += f'Rprof({json.dumps(str(Path(args.profile).resolve()))}, interval=0.001); ' program += 'languageserver::run(); Rprof(NULL)' stderr = open(root / "stderr.log", "wb") +launched_at = time.monotonic() process = subprocess.Popen(["Rscript", "--vanilla", "-e", program], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr, cwd=root) next_id = 0 @@ -45,6 +55,7 @@ requests = {} responses = {} background_responses = {} +diagnostic_publications = [] reader_failure = None reader_finished = False bytes_received = 0 @@ -95,6 +106,19 @@ def read_responses(): if "method" in payload: if "id" in payload: raise RuntimeError(f"Unexpected server request: {payload}") + if payload["method"] == "textDocument/publishDiagnostics": + params = payload["params"] + diagnostics = params.get("diagnostics", []) + if any(item.get("message", "").startswith("Failed to run diagnostics:") + for item in diagnostics): + raise RuntimeError(f"Diagnostics failed: {diagnostics}") + with condition: + diagnostic_publications.append({ + "uri": params["uri"], "version": params.get("version"), + "count": len(diagnostics), + "startup_ms": round((time.monotonic() - launched_at) * 1000, 2) + }) + condition.notify_all() continue # Notifications need no response in this fixture. with condition: response_id = payload.get("id") @@ -166,6 +190,20 @@ def pause(duration): check_reader() +def receive_diagnostics(uri, version, timeout=120): + deadline = time.monotonic() + timeout + with condition: + while True: + check_reader() + for publication in diagnostic_publications: + if publication["uri"] == uri and publication["version"] == version: + return dict(publication) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"No diagnostics for version {version} within {timeout}s") + condition.wait(remaining) + + # Editors keep consuming stdout between keystrokes. Continuously drain the # pipe here too, otherwise large background responses block the server during # the pauses and artificially inflate the following completion measurement. @@ -175,20 +213,32 @@ def pause(duration): try: receive(send("initialize", {"rootUri": root.as_uri(), "capabilities": { "textDocument": {"completion": {"completionItem": {"snippetSupport": True}}}}})) + print(json.dumps({"startup": { + "initialize_ms": round((time.monotonic() - launched_at) * 1000, 2), + "diagnostics": args.diagnostics, "lines": args.lines, "index": args.index + }}), flush=True) send("initialized", {}, False) uri = file.as_uri() + opened_at = time.monotonic() send("textDocument/didOpen", {"textDocument": {"uri": uri, "languageId": "r", "version": 1, "text": "\n".join(lines)}}, False) doc = {"textDocument": {"uri": uri}} previous = "" version = 1 pause(args.open_delay) + if args.wait_diagnostics: + publication = receive_diagnostics(uri, version) + print(json.dumps({"initial_diagnostics": publication}), flush=True) for round_number in range(1, args.rounds + 1): if args.wait_parse or round_number > 1: started = time.monotonic() receive(send("textDocument/documentSymbol", doc)) - print(json.dumps({"round": round_number, "prepare_ms": round( - (time.monotonic() - started) * 1000, 2)}), flush=True) + prepared_at = time.monotonic() + preparation = {"round": round_number, "prepare_ms": round( + (prepared_at - started) * 1000, 2)} + if round_number == 1: + preparation["open_to_symbols_ms"] = round((prepared_at - opened_at) * 1000, 2) + print(json.dumps(preparation), flush=True) pause(0.1) if args.providers: send("textDocument/documentSymbol", doc, background=True) @@ -203,10 +253,17 @@ def pause(duration): "contentChanges": [{"range": {"start": {"line": args.lines, "character": 0}, "end": {"line": args.lines, "character": len(previous)}}, "text": token}]}, False) result = receive(send("textDocument/completion", {**doc, "position": point})) - print(json.dumps({"round": round_number, "token": token, + measurement = {"round": round_number, "token": token, "elapsed_ms": round((time.monotonic() - start) * 1000, 2), - "items": len(result.get("items", []))}), flush=True) + "items": len(result.get("items", []))} + if round_number == 1 and token == "v": + measurement["startup_to_first_completion_ms"] = round( + (time.monotonic() - launched_at) * 1000, 2) + print(json.dumps(measurement), flush=True) previous = token + if args.wait_diagnostics: + publication = receive_diagnostics(uri, version) + print(json.dumps({"final_diagnostics": publication}), flush=True) receive(send("shutdown", {})) exit_code = process.wait(timeout=10) reader.join(timeout=10) @@ -221,7 +278,9 @@ def pause(duration): "bytes_received": bytes_received, "providers_completed": sum(item["error"] is None for item in background_responses.values()), "providers_cancelled": sum(item["error"] is not None for item in background_responses.values()), - "providers_pending": sum(background for _, background in requests.values()) + "providers_pending": sum(background for _, background in requests.values()), + "diagnostic_publications": len(diagnostic_publications), + "diagnostic_versions": [item["version"] for item in diagnostic_publications] }}), flush=True) finally: closing.set() From 070bec70a810e39fc9d675d729cd076eefd31dfb Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:19:10 +0800 Subject: [PATCH 5/7] ci: publish CRAN releases with verified tags and modern authentication --- .github/.gitignore | 1 + .github/RELEASING.md | 53 +++++++ .github/scripts/cran_release.py | 192 +++++++++++++++++++++++ .github/scripts/test_cran_release.py | 225 +++++++++++++++++++++++++++ .github/workflows/tagbot.yml | 49 ++++-- DESCRIPTION | 3 +- 6 files changed, 512 insertions(+), 11 deletions(-) create mode 100644 .github/RELEASING.md create mode 100644 .github/scripts/cran_release.py create mode 100644 .github/scripts/test_cran_release.py diff --git a/.github/.gitignore b/.github/.gitignore index 2d19fc76..e32f10ff 100644 --- a/.github/.gitignore +++ b/.github/.gitignore @@ -1 +1,2 @@ *.html +__pycache__/ diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 00000000..5335d1de --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,53 @@ +# CRAN and GitHub releases + +Finalize the release notes in `NEWS.md` and set the release `Version` in +`DESCRIPTION` when the release is ready. Merge that release to the default branch +before submitting to CRAN. After release, bump `Version` to a development version +and remove `Date`; start a new development section in `NEWS.md`. + +The **CRAN release** workflow checks CRAN's source package index every six hours. +It uses the first commit on the default branch whose committed `DESCRIPTION` +matches CRAN's package and version. This is normally the release merge commit. +It takes release notes from that commit's matching `NEWS.md` section, creates +`v` at that exact commit, and publishes a GitHub release. Later changes +to `DESCRIPTION`, `NEWS.md`, or other files cannot change the selected source. + +The version bump must identify the finalized release. If additional release +fixes were made after that version first entered the default branch, review the +source commit manually before publication. The workflow deliberately fails when +an existing tag points elsewhere; it never moves tags or rewrites releases. +Existing drafts and prereleases also require manual review. A failed run that +created the correct tag can safely be rerun to finish publishing its release. + +Use **Actions → CRAN release → Run workflow** to preview the selected commit and +release notes. `dry_run` is enabled by default. Uncheck it to publish immediately +after reviewing the preview. The workflow always checks out the default branch; +the script also reads GitHub's current default-branch commit before selecting +release history, including when invoked from a local feature branch. + +The script requires Python 3 and Git, without third-party Python or R packages. +It uses the built-in `GITHUB_TOKEN` through `GH_TOKEN`; no personal token is +needed. Only the publication job has `contents: write`. Publications run one at +a time. Pull requests affecting the workflow or script run offline safety tests +with read-only permissions. + +For local review, first fetch the default branch's full history. An authenticated +shell with `GH_TOKEN` or `GITHUB_TOKEN` can then preview the live CRAN/GitHub state: + +```sh +python3 .github/scripts/cran_release.py --repository REditorSupport/languageserver +``` + +Adding `--publish` creates the tag and release. It requires a token, the live +CRAN index, and access to GitHub. No token contents are printed. A complete +offline preview instead uses the local `HEAD` and an uncompressed CRAN-style +`PACKAGES` file containing `Package:` and `Version:` fields: + +```sh +python3 .github/scripts/cran_release.py --cran-index /tmp/PACKAGES --plan-only +python3 -m unittest discover -s .github/scripts -p 'test_cran_release.py' -v +``` + +Offline inputs cannot be combined with `--publish`. If the current remote default +branch commit is missing locally, fetch it and retry; the script will not fall +back to a potentially stale local branch. diff --git a/.github/scripts/cran_release.py b/.github/scripts/cran_release.py new file mode 100644 index 00000000..f359b03b --- /dev/null +++ b/.github/scripts/cran_release.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Publish the current CRAN release from its original default-branch commit. + +Uses only Python's standard library and git. The default is a read-only preview; +--publish explicitly enables GitHub writes. GH_TOKEN accepts the workflow token +without imposing personal-access-token format restrictions. +""" + +import argparse +import gzip +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +CRAN_INDEX = "https://cran.r-project.org/src/contrib/PACKAGES.gz" + + +def dcf_records(text): + """Read the Package/Version fields from CRAN's DCF index or DESCRIPTION.""" + for paragraph in re.split(r"\n\s*\n", text.strip()): + fields = {} + for line in paragraph.splitlines(): + if line and not line[0].isspace() and ":" in line: + key, value = line.split(":", 1) + fields[key] = value.strip() + if fields: + yield fields + + +def cran_version(package, index=None): + if index is None: + with urlopen(CRAN_INDEX, timeout=30) as response: + index = gzip.decompress(response.read()).decode("utf-8") + versions = [record.get("Version", "") for record in dcf_records(index) + if record.get("Package") == package] + if len(versions) != 1 or not re.fullmatch(r"[0-9]+(?:[.-][0-9]+)+", versions[0]): + raise RuntimeError(f"CRAN must list exactly one valid version of {package}") + return versions[0] + + +def git(*args, cwd=None): + return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip() + + +def release_commit(package, version, cwd=None, ref="HEAD"): + """Find when the release version first entered the default branch. + + Walking only DESCRIPTION changes, oldest first, avoids tagging subsequent + development commits even if maintainers have not bumped Version yet. The + workflow checks out the default branch with its full history, and live runs + resolve ref from GitHub's current default branch rather than the local HEAD. + """ + commits = git("log", "--first-parent", "--reverse", "--format=%H", + ref, "--", "DESCRIPTION", cwd=cwd).splitlines() + for commit in commits: + description = git("show", f"{commit}:DESCRIPTION", cwd=cwd) + fields = next(dcf_records(description)) + if fields.get("Package") == package and fields.get("Version") == version: + return commit + raise RuntimeError(f"No committed DESCRIPTION matches CRAN's {package} {version}") + + +def release_notes(package, version, commit, cwd=None): + news = git("show", f"{commit}:NEWS.md", cwd=cwd) + heading = re.compile(r"^# " + re.escape(package) + r" " + re.escape(version) + r"\s*$") + lines = news.splitlines() + for start, line in enumerate(lines): + if heading.fullmatch(line): + end = next((i for i in range(start + 1, len(lines)) + if lines[i].startswith("# ")), len(lines)) + notes = "\n".join(lines[start + 1:end]).strip() + if notes: + return notes + "\n" + raise RuntimeError(f"Missing nonempty NEWS.md section for {package} {version} at {commit}") + + +class GitHub: + def __init__(self, repository, token=None): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise RuntimeError("Specify a GitHub repository as owner/name") + self.base = f"https://api.github.com/repos/{repository}" + self.token = token + + def request(self, method, path, data=None, allow_missing=False): + headers = {"Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "languageserver-cran-release"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + payload = None if data is None else json.dumps(data).encode("utf-8") + if payload is not None: + headers["Content-Type"] = "application/json" + request = Request(self.base + path, data=payload, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except HTTPError as error: + if allow_missing and error.code == 404: + return None + raise RuntimeError(f"GitHub {method} {path} returned HTTP {error.code}") from error + + +def default_branch_head(api): + branch = api.request("GET", "")["default_branch"] + obj = api.request("GET", f"/git/ref/heads/{quote(branch, safe='')}")["object"] + if obj["type"] != "commit" or not re.fullmatch(r"[0-9a-f]{40}", obj["sha"]): + raise RuntimeError("GitHub's default branch must resolve to a commit") + return obj["sha"] + + +def tag_commit(api, tag): + ref = api.request("GET", f"/git/ref/tags/{tag}", allow_missing=True) + if ref is None: + return None + obj = ref["object"] + # Annotated tags may in turn reference another annotated tag. + for _ in range(10): + if obj["type"] == "commit": + return obj["sha"] + if obj["type"] != "tag": + break + obj = api.request("GET", f"/git/tags/{obj['sha']}")["object"] + raise RuntimeError(f"Tag {tag} does not resolve to a commit") + + +def publish_release(api, version, commit, notes, publish=False): + tag = f"v{version}" + target = tag_commit(api, tag) + if target is not None and target != commit: + raise RuntimeError(f"Refusing to move {tag}: points to {target}, expected {commit}") + release = api.request("GET", f"/releases/tags/{tag}", allow_missing=True) + if release is not None: + if target is None or release["draft"] or release["prerelease"]: + raise RuntimeError(f"Existing release {tag} needs manual review") + print(f"Already published: {release['html_url']} ({commit})") + return release + print(f"{'Publish' if publish else 'Preview'}: {tag} at {commit}") + print(notes) + if not publish: + return None + if target is None: + api.request("POST", "/git/refs", {"ref": f"refs/tags/{tag}", "sha": commit}) + # Check again before publishing: never silently release a conflicting tag. + if tag_commit(api, tag) != commit: + raise RuntimeError(f"Tag {tag} changed before release publication") + release = api.request("POST", "/releases", { + "tag_name": tag, "target_commitish": commit, "name": tag, + "body": notes, "draft": False, "prerelease": False, + }) + print(f"Published: {release['html_url']}") + return release + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument("--cran-index", type=Path, help="Read a local PACKAGES file for offline review") + parser.add_argument("--plan-only", action="store_true", help="Skip GitHub reads for offline review") + parser.add_argument("--publish", action="store_true", help="Create the GitHub tag and release") + args = parser.parse_args() + if args.publish and (args.plan_only or args.cran_index): + parser.error("--publish requires the live CRAN index and GitHub checks") + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if args.publish and not token: + parser.error("--publish requires GH_TOKEN or GITHUB_TOKEN") + if not args.plan_only and not args.repository: + parser.error("--repository or GITHUB_REPOSITORY is required") + api = None if args.plan_only else GitHub(args.repository, token) + ref = "HEAD" if args.plan_only else default_branch_head(api) + package = next(dcf_records(git("show", f"{ref}:DESCRIPTION")))["Package"] + index = args.cran_index.read_text() if args.cran_index else None + version = cran_version(package, index) + commit = release_commit(package, version, ref=ref) + notes = release_notes(package, version, commit) + if args.plan_only: + print(f"Preview: v{version} at {commit}\n\n{notes}") + return + publish_release(api, version, commit, notes, args.publish) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, subprocess.CalledProcessError) as error: + sys.exit(str(error)) diff --git a/.github/scripts/test_cran_release.py b/.github/scripts/test_cran_release.py new file mode 100644 index 00000000..7a877667 --- /dev/null +++ b/.github/scripts/test_cran_release.py @@ -0,0 +1,225 @@ +"""Offline tests of release selection, permissions, and recovery after failure.""" + +import contextlib +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, call, patch +from urllib.error import HTTPError + +import cran_release as release + + +class HistoryTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.repo = Path(self.directory.name) + release.git("init", "-q", "-b", "main", cwd=self.repo) + release.git("config", "user.name", "Release test", cwd=self.repo) + release.git("config", "user.email", "release-test@example.invalid", cwd=self.repo) + release.git("config", "commit.gpgsign", "false", cwd=self.repo) + release.git("config", "core.hooksPath", "/dev/null", cwd=self.repo) + self.commit("0.3.18", "Old release") + + def commit(self, version, message): + (self.repo / "DESCRIPTION").write_text( + f"Package: languageserver\nVersion: {version}\nTitle: {message}\n") + (self.repo / "NEWS.md").write_text( + "# languageserver 0.3.19\n\n- Released fix.\n\n" + "# languageserver 0.3.18\n\n- Older fix.\n") + release.git("add", ".", cwd=self.repo) + release.git("commit", "-qm", message, cwd=self.repo) + return release.git("rev-parse", "HEAD", cwd=self.repo) + + def test_selects_release_before_unbumped_and_bumped_development(self): + expected = self.commit("0.3.19", "Release") + self.commit("0.3.19", "Development without version bump") + self.commit("0.3.19.9000", "Development version") + (self.repo / "DESCRIPTION").write_text("Package: languageserver\nVersion: 9.9.9\n") + self.assertEqual(release.release_commit("languageserver", "0.3.19", self.repo), expected) + self.assertEqual(release.release_notes("languageserver", "0.3.19", expected, self.repo), + "- Released fix.\n") + + def test_selects_merge_where_version_enters_default_branch(self): + release.git("checkout", "-qb", "release", cwd=self.repo) + self.commit("0.3.19", "Release branch") + release.git("checkout", "-q", "main", cwd=self.repo) + release.git("merge", "--no-ff", "-qm", "Merge release", "release", cwd=self.repo) + expected = release.git("rev-parse", "HEAD", cwd=self.repo) + self.commit("0.3.19.9000", "Development version") + self.assertEqual(release.release_commit("languageserver", "0.3.19", self.repo), expected) + + def test_unmerged_and_prefix_versions_do_not_match(self): + release.git("checkout", "-qb", "release", cwd=self.repo) + self.commit("0.3.19", "Unmerged release") + release.git("checkout", "-q", "main", cwd=self.repo) + self.commit("0.3.19.9000", "Development version") + with self.assertRaisesRegex(RuntimeError, "No committed DESCRIPTION"): + release.release_commit("languageserver", "0.3.19", self.repo) + + def test_explicit_default_branch_ref_excludes_local_feature_commits(self): + default_head = release.git("rev-parse", "HEAD", cwd=self.repo) + self.commit("0.3.19", "Unmerged feature version") + with self.assertRaisesRegex(RuntimeError, "No committed DESCRIPTION"): + release.release_commit("languageserver", "0.3.19", self.repo, ref=default_head) + + def test_missing_release_notes_abort(self): + expected = self.commit("0.3.20", "Release with missing notes") + with self.assertRaisesRegex(RuntimeError, "Missing nonempty NEWS"): + release.release_notes("languageserver", "0.3.20", expected, self.repo) + + +class FakeGitHub: + def __init__(self, target=None, published=False, annotated=False): + self.target = target + self.annotated = annotated + self.release = ({"html_url": "https://example.invalid/v0.3.19", + "draft": False, "prerelease": False} if published else None) + self.writes = [] + self.fail_release = False + + def request(self, method, path, data=None, allow_missing=False): + if method == "GET" and path == "/git/ref/tags/v0.3.19": + if self.target is None: + return None + return {"object": {"type": "tag" if self.annotated else "commit", "sha": self.target}} + if method == "GET" and path == f"/git/tags/{self.target}": + return {"object": {"type": "commit", "sha": self.target}} + if method == "GET" and path == "/releases/tags/v0.3.19": + return self.release + if method == "POST": + self.writes.append((path, data)) + if path == "/git/refs": + self.target = data["sha"] + return {} + if path == "/releases": + if self.fail_release: + raise RuntimeError("GitHub temporary error") + self.release = {"html_url": "https://example.invalid/v0.3.19", + "draft": data["draft"], "prerelease": data["prerelease"]} + return self.release + raise AssertionError(f"Unexpected API call: {method} {path}") + + +class PublishTests(unittest.TestCase): + def setUp(self): + self.output = contextlib.redirect_stdout(io.StringIO()) + self.output.__enter__() + self.addCleanup(self.output.__exit__, None, None, None) + + def publish(self, api, publish=True): + return release.publish_release(api, "0.3.19", "release-commit", "Release notes\n", publish) + + def test_preview_makes_no_writes(self): + api = FakeGitHub() + self.publish(api, publish=False) + self.assertEqual(api.writes, []) + + def test_publish_uses_exact_commit_and_notes_and_is_idempotent(self): + api = FakeGitHub() + self.publish(api) + self.publish(api) + self.assertEqual(api.writes, [ + ("/git/refs", {"ref": "refs/tags/v0.3.19", "sha": "release-commit"}), + ("/releases", {"tag_name": "v0.3.19", "target_commitish": "release-commit", + "name": "v0.3.19", "body": "Release notes\n", + "draft": False, "prerelease": False}), + ]) + + def test_existing_lightweight_and_annotated_tags_are_reused(self): + for annotated in (False, True): + with self.subTest(annotated=annotated): + api = FakeGitHub(target="release-commit", annotated=annotated) + self.publish(api) + self.assertEqual([path for path, _ in api.writes], ["/releases"]) + + def test_conflicting_tag_is_never_modified_even_with_existing_release(self): + for published in (False, True): + with self.subTest(published=published): + api = FakeGitHub(target="wrong-commit", published=published) + with self.assertRaisesRegex(RuntimeError, "Refusing to move"): + self.publish(api) + self.assertEqual(api.writes, []) + + def test_existing_draft_prerelease_or_missing_tag_requires_review(self): + for state in ("draft", "prerelease", "missing-tag"): + with self.subTest(state=state): + api = FakeGitHub(target="release-commit", published=True) + if state == "missing-tag": + api.target = None + else: + api.release[state] = True + with self.assertRaisesRegex(RuntimeError, "manual review"): + self.publish(api) + self.assertEqual(api.writes, []) + + def test_recovers_from_release_failure_after_tag_creation(self): + api = FakeGitHub() + api.fail_release = True + with self.assertRaisesRegex(RuntimeError, "temporary error"): + self.publish(api) + api.fail_release = False + self.publish(api) + self.assertEqual([path for path, _ in api.writes].count("/git/refs"), 1) + self.assertIsNotNone(api.release) + + def test_changed_tag_aborts_publication(self): + api = FakeGitHub() + with patch.object(release, "tag_commit", side_effect=[None, "another-commit"]): + with self.assertRaisesRegex(RuntimeError, "changed before release"): + self.publish(api) + self.assertEqual([path for path, _ in api.writes], ["/git/refs"]) + + +class InputAndAPITests(unittest.TestCase): + def test_cran_index_requires_unique_exact_package(self): + index = "Package: other\nVersion: 9.0\n\nPackage: languageserver\nVersion: 0.3.19\n" + self.assertEqual(release.cran_version("languageserver", index), "0.3.19") + for invalid in ("", index + "\n" + index, "Package: languageserver\nVersion: bad", + "Package: languageserver"): + with self.subTest(index=invalid): + with self.assertRaisesRegex(RuntimeError, "exactly one valid version"): + release.cran_version("languageserver", invalid) + + def test_live_runs_resolve_the_remote_default_branch_commit(self): + api = Mock() + api.request.side_effect = [{"default_branch": "main"}, + {"object": {"type": "commit", "sha": "a" * 40}}] + self.assertEqual(release.default_branch_head(api), "a" * 40) + self.assertEqual(api.request.call_args_list, + [call("GET", ""), call("GET", "/git/ref/heads/main")]) + + def test_workflow_token_is_sent_as_bearer_without_pat_validation(self): + api = release.GitHub("owner/repo", "ghs_workflow-token") + with patch.object(release, "urlopen", return_value=io.BytesIO(b'{}')) as open_url: + api.request("GET", "/releases/tags/v0.3.19") + request = open_url.call_args.args[0] + self.assertEqual(request.get_header("Authorization"), "Bearer ghs_workflow-token") + self.assertEqual(request.get_header("X-github-api-version"), "2022-11-28") + + def test_only_404_is_treated_as_absent(self): + api = release.GitHub("owner/repo") + for status in (401, 403, 404, 429, 500): + with self.subTest(status=status): + error = HTTPError("https://example.invalid", status, "error", {}, None) + with patch.object(release, "urlopen", side_effect=error): + if status == 404: + self.assertIsNone(api.request("GET", "/releases/tags/v0.3.19", allow_missing=True)) + else: + with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"): + api.request("GET", "/releases/tags/v0.3.19", allow_missing=True) + + def test_publish_cannot_use_offline_inputs(self): + for option in ("--plan-only", "--cran-index=PACKAGES"): + with self.subTest(option=option): + with patch("sys.argv", ["cran_release.py", "--publish", option]): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: + release.main() + self.assertEqual(error.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tagbot.yml b/.github/workflows/tagbot.yml index 7a4407af..20faf2e1 100644 --- a/.github/workflows/tagbot.yml +++ b/.github/workflows/tagbot.yml @@ -1,21 +1,52 @@ -name: TagBot +name: CRAN release on: schedule: - # every six hour - - cron: 0 */6 * * * + - cron: '0 */6 * * *' + workflow_dispatch: + inputs: + dry_run: + description: Preview the release without creating a tag or release + type: boolean + default: true + pull_request: + paths: + - '.github/workflows/tagbot.yml' + - '.github/scripts/cran_release.py' + - '.github/scripts/test_cran_release.py' + +permissions: + contents: read jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - run: python3 -m unittest discover -s .github/scripts -p 'test_cran_release.py' -v + publish-github-release: + needs: test + if: github.event_name != 'pull_request' && github.repository == 'REditorSupport/languageserver' runs-on: ubuntu-latest - container: rtagbot/tagbot:latest + permissions: + contents: write + concurrency: + group: cran-release-publish + cancel-in-progress: false steps: - uses: actions/checkout@v7 with: + ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 - - name: check and publish release - run: | - tagbot::publish_release() - shell: Rscript {0} + persist-credentials: false + - name: Check CRAN and publish the matching release env: - GITHUB_PAT: ${{secrets.GITHUB_TOKEN}} + GH_TOKEN: ${{ github.token }} + PUBLISH: ${{ github.event_name == 'schedule' || !inputs.dry_run }} + run: | + args=() + if [[ "$PUBLISH" == true ]]; then args+=(--publish); fi + python3 .github/scripts/cran_release.py "${args[@]}" diff --git a/DESCRIPTION b/DESCRIPTION index f3a3bad3..e4b8428a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,7 @@ Type: Package Package: languageserver Title: Language Server Protocol -Version: 0.3.19 -Date: 2026-09-11 +Version: 0.3.19.9000 Authors@R: c(person(given = "Randy", family = "Lai", From ed7816b599313a5fdb5ff8cd904a2794005e5f9c Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:19:10 +0800 Subject: [PATCH 6/7] docs: update editor setup and record post-release follow-ups --- .github/post-release-triage.md | 101 +++++++++++++++++++++++++++++++++ NEWS.md | 17 ++++++ README.md | 43 +++++++------- 3 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 .github/post-release-triage.md diff --git a/.github/post-release-triage.md b/.github/post-release-triage.md new file mode 100644 index 00000000..09dab5e9 --- /dev/null +++ b/.github/post-release-triage.md @@ -0,0 +1,101 @@ +# Post-CRAN triage: 0.3.19 + +Reviewed on 2026-09-14 against the released features, current source and tests, +and the open GitHub issues. The four items below were closed on that date; +the remaining items retain the follow-up requirements documented here. + +## Closed as implemented or superseded + +| Item | Evidence | Disposition | +| --- | --- | --- | +| [#94: extract/inline refactoring](https://github.com/REditorSupport/languageserver/issues/94) | [PR #753](https://github.com/REditorSupport/languageserver/pull/753), released in 0.3.19, adds extract-variable, extract-function, and single-use local-variable inlining. [Refactoring tests](../tests/testthat/test-refactor.R) cover free variables, live-out bindings, edit versions, unsafe contexts, and literate-cell boundaries. | Closed as implemented. Applicability remains conservative; arbitrary R transformations are not promised. This reconciles GitHub with the existing NEWS entry. | +| [#307: semantic tokens](https://github.com/REditorSupport/languageserver/issues/307) | Semantic tokens shipped in 0.3.17; 0.3.19 adds cached tokens and deltas. [Provider tests](../tests/testthat/test-semantic-tokens.R) cover full, range, delta reconstruction, UTF-16 positions, and function declarations. | Closed as implemented; specific semantic-token bugs remain in their own issues. | +| [#396: diagnostic code actions](https://github.com/REditorSupport/languageserver/issues/396) | [PR #746](https://github.com/REditorSupport/languageserver/pull/746), released in 0.3.19, provides preferred fixes for common linters and conflict-aware `source.fixAll`. [Code-action tests](../tests/testthat/test-code-action.R) cover assignment, commas, logical constants, layout, suppression, and overlapping fixes. | Closed as implemented. An upstream lintr edit API would be a separate integration enhancement. | +| [Draft PR #397](https://github.com/REditorSupport/languageserver/pull/397) | Its initial diagnostic-fix implementation is superseded by merged PR #746. | Closed as superseded; the original proposal remains available in the PR history. | + +## Keep open or close after follow-up + +### #621: multi-root workspaces — retain the profile-isolation requirement + +[PR #719](https://github.com/REditorSupport/languageserver/pull/719), released +in 0.3.18, implements workspace-folder routing. The +[workspace tests](../tests/testthat/test-workspace.R) verify symbol lookup, +dynamic folder addition, and fallback-workspace cleanup. This satisfies much +of [#621](https://github.com/REditorSupport/languageserver/issues/621). + +The issue also asks for each folder's `.Rprofile` and settings to be respected. +[`workspace_startup_packages()`](../R/workspace.R) currently shares one cached +startup-package list across workspaces, and [task workers](../R/task.R) start +with common process/profile options rather than a folder-specific startup +directory. Keep the issue open for this remaining work. Acceptance should use +two folders with different profiles and library paths and verify that package +resolution and diagnostics remain isolated, including after folder removal +and addition. Folder-symbol tests alone do not establish profile isolation. + +### #726: diagnostics configuration — distinguish the original fix from new cases + +The original [#726 report](https://github.com/REditorSupport/languageserver/issues/726) +used languageserver 0.3.16 with lintr 3.3.0.1. Explicit `parse_settings = TRUE` +shipped in 0.3.17 through +[PR #706](https://github.com/REditorSupport/languageserver/pull/706), as noted +in the existing issue reply. The stabilization changes additionally preserve +the path for nested configuration and exclusions in saved and new `.R`, +`.Rmd`, and `.qmd` buffers. The +[configuration regression tests](../tests/testthat/test-diagnostics-configuration.R) +exercise these cases and restore lintr settings after failures. + +Close with the original fix and the stabilization release version once these +additional changes land. If the reporter still reproduces the saved `.R` +case, request their `.lintr`, exact paths, and installed package versions; +do not assume every configuration report has the same cause. + +### #735: Quarto semantic tokens — await editor confirmation + +[PR #752](https://github.com/REditorSupport/languageserver/pull/752) scopes +providers to literate R cells. Parsing the exact document from +[#735](https://github.com/REditorSupport/languageserver/issues/735) now produces +semantic tokens only on line 19 (`print("hello")`, using one-based lines); +YAML, prose, math, and cell metadata produce none. The +[literate-document tests](../tests/testthat/test-literate.R) also exercise +region isolation and Quarto requests through the server. + +Keep open pending confirmation in Cursor/VS Code with the Quarto extension +and languageserver 0.3.19 or later. A maintainer already requested confirmation +of #752 on 2026-08-24; avoid posting a duplicate request. If it persists, obtain +the extension versions and actual semantic-token response to distinguish +server output from the client's embedded-document mapping. + +### #731 and #687: startup and completion performance — retain reproducible workloads + +[#731](https://github.com/REditorSupport/languageserver/issues/731) reports +a worker startup timeout with Windows, R 4.6.0, and an `renv` profile. Workers +now start asynchronously and task failures are contained, but these changes +do not prove that slow `renv` activation completes successfully. Retain the +issue until a minimal project is exercised on that platform, recording cold +startup time and whether worker readiness and diagnostics eventually succeed. + +[#687](https://github.com/REditorSupport/languageserver/issues/687) reports +repeated 20-second completion stalls on a remote cluster. 0.3.19 improves +package-resolution reuse, caching, and scheduling, but local warm-provider +timings cannot establish a fix for remote filesystem and package startup +costs. Use the [typing benchmark](../inst/benchmarks/README.md) with cold-start +and diagnostics-enabled measurements. Ask for a minimal package list, remote +filesystem details, and timings before and after idle periods if the problem +persists; close only after reproducing the workload or receiving confirmation. + +### PR #665: formatting-style documentation — review independently + +[PR #665](https://github.com/REditorSupport/languageserver/pull/665) adds a +`styler.equals::equals_style()` example for +[#664](https://github.com/REditorSupport/languageserver/issues/664). It does +not repair the Neovim setup. Keep it separate from the current README fixes +and verify the suggested package API and intended style before merging it. +The existing README already demonstrates disabling the assignment-operator +rewrite directly; avoid presenting the new dependency as required. + +## Applying this review + +Track the remaining items by their concrete next action: profile isolation, +regression verification, or an environment-specific reproduction. Include the shipped version and +linked tests when resolving a feature request. Do not close a reproducible +bug merely because a related feature or performance improvement shipped. diff --git a/NEWS.md b/NEWS.md index 75aee377..acaafa10 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,20 @@ +# languageserver (development version) + +- Honor nested `.lintr` settings and exclusions in Quarto, R Markdown, and new + unsaved file buffers, including with lintr 3.0.0. +- Clear existing diagnostics and cancel queued work when diagnostics are + disabled, while rejecting late background results. +- Run document and explicit-selection formatting in a background worker so + other editor requests remain responsive. Cancel formatting when requested by + the client or when its document changes, and preserve custom formatting + options and literate-document boundaries. +- Correct the standalone Neovim setup instructions and include Quarto files. +- Extend typing benchmarks with cold-start measurements and verified + diagnostics-enabled workloads. +- Check R oldrel and devel in CI and schedule native-memory checks with + AddressSanitizer, Valgrind, and rchk. +- Repair CRAN-to-GitHub release automation and start development after 0.3.19. + # languageserver 0.3.19 - Add a shared Quarto/R Markdown region model with `.qmd` and Quarto language diff --git a/README.md b/README.md index 2b364e7c..5d89be14 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CRAN Downloads](https://cranlogs.r-pkg.org/badges/grand-total/languageserver)](https://cran.r-project.org/package=languageserver) [![r-universe](https://reditorsupport.r-universe.dev/badges/languageserver)](https://reditorsupport.r-universe.dev/#package:languageserver) -`languageserver` is an implementation of the Microsoft's [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) for the language of R. +`languageserver` is an implementation of Microsoft's [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) for R. - [Installation](#installation) - [Language Clients](#language-clients) @@ -64,22 +64,23 @@ The following editors are supported by installing the corresponding extensions: - Sublime Text: [R-IDE](https://github.com/REditorSupport/sublime-ide-r) -- NeoVim: NeoVim's LSP client with settings +- Neovim 0.11 or later: install the R package above, ensure `R` is on your + `PATH`, and add this to `init.lua` to use the built-in LSP client: ```lua - vim.lsp.config['r_language_server'] = { - settings = { - filetypes = { "r", "rmd" }, - }, - } - vim.api.nvim_create_autocmd("FileType", { - pattern = { "r", "rmd" }, - callback = function() - vim.lsp.start(vim.lsp.config["r_language_server"]) - end, - } + vim.filetype.add({ extension = { qmd = "quarto" } }) + vim.lsp.config("r_language_server", { + cmd = { "R", "--no-echo", "--no-restore", "-e", "languageserver::run()" }, + filetypes = { "r", "rmd", "quarto" }, + root_markers = { "DESCRIPTION", ".git" }, + }) + vim.lsp.enable("r_language_server") ``` + Open an `.R`, `.Rmd`, or `.qmd` file and run `:checkhealth vim.lsp` to + check the server configuration and connection. This setup does not require + `nvim-lspconfig`. + or, if you use [coc.nvim](https://github.com/neoclide/coc.nvim), you can do one of two things: - Install [coc-r-lsp](https://github.com/neoclide/coc-r-lsp) with: @@ -92,7 +93,7 @@ The following editors are supported by installing the corresponding extensions: ```r install.packages("languageserver") - # or install the developement version + # or install the development version # remotes::install_github("REditorSupport/languageserver") ``` @@ -114,7 +115,8 @@ The following editors are supported by installing the corresponding extensions: (use-package ess :ensure t) (add-hook 'ess-r-mode-hook 'eglot-ensure) ``` - To check if it is working, open an R file, place the cursor on a line and run `M-x ess-eval-line`. + Open an R file and use `M-x eglot-events-buffer` to inspect the server + connection, or `M-x completion-at-point` to request completions. - Emacs: [lsp-mode](https://github.com/emacs-lsp/lsp-mode) @@ -199,15 +201,15 @@ settings | default | description `r.lsp.index_time_budget_ms` | `25` | approximate event-loop budget for each shallow-index batch `r.lsp.index_persistent_cache` | `true` | persist validated shallow summaries in the user cache directory `r.lsp.server_capabilities` | `{}` | override server capabilities defined in [capabilities.R](https://github.com/REditorSupport/languageserver/blob/master/R/capabilities.R). See FAQ below. -`r.lsp.link_file_size_limit` | 16384 | maximum file size (in bytes) that supports document links +`r.lsp.link_file_size_limit` | `16777216` (16 MiB) | maximum file size (in bytes) that supports document links -These settings could also specified in `.Rprofile` file via `options(languageserver. = )`. For example, +These settings can also be specified in an `.Rprofile` file via `options(languageserver. = )`. For example, ```r options(languageserver.snippet_support = FALSE) ``` -will turn off snippet support globally. LSP configuration settings are always overriden by `options()`. +will turn off snippet support for servers started with that profile. LSP configuration settings are always overridden by `options()`. Project indexing is deliberately two-tiered. Package `R/` files, open files, and the transitive dependencies of static `source()` or `sys.source()` calls @@ -221,7 +223,10 @@ recognized; project code is never executed to resolve a path. ### Linters -With [lintr](https://github.com/r-lib/lintr) v2.0.0, the linters can be specified by creating the `.lintr` file at the project or home directory. Details can be found at lintr [documentation](https://lintr.r-lib.org/articles/lintr.html). +Configure [lintr](https://github.com/r-lib/lintr) by creating a `.lintr` file in +the project or home directory. The package requires lintr 3.0.0 or later; see +the [lintr documentation](https://lintr.r-lib.org/articles/lintr.html) for +configuration options. ### Customizing server capabilities From a0660abe04cd9e4403e4bf81110d6e308057baf8 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 14 Sep 2026 07:35:35 +0800 Subject: [PATCH 7/7] fix: normalize Windows lint paths before applying exclusions --- R/diagnostics.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/R/diagnostics.R b/R/diagnostics.R index a434d90a..a46c55f8 100644 --- a/R/diagnostics.R +++ b/R/diagnostics.R @@ -92,7 +92,8 @@ lint_literate_file <- function(path, content, linters, cache) { # before applying the config's file and line exclusions. Inline nolint # comments have already been applied by lint(). lints[] <- lapply(lints, function(lint) { - lint$filename <- normalizePath(path, mustWork = FALSE) + # lintr normalizes exclusion paths to forward slashes on Windows too. + lint$filename <- normalizePath(path, winslash = "/", mustWork = FALSE) lint }) lintr_namespace$exclude(lints, lines = character())