From 9b3f20a9cc317efe8541081136abf87bf1698233 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:21:21 +0800 Subject: [PATCH 01/16] Fix literal argument completion and avoid repeated default copying --- R/completion.R | 80 +++++++++---------- .../testthat/test-completion-default-values.R | 52 ++++++++++++ 2 files changed, 89 insertions(+), 43 deletions(-) create mode 100644 tests/testthat/test-completion-default-values.R diff --git a/R/completion.R b/R/completion.R index 00508dec..baf0ea8d 100644 --- a/R/completion.R +++ b/R/completion.R @@ -186,29 +186,33 @@ extract_default_values <- function(default_expr) { return(NULL) } - # If it's a call to c(), extract the arguments - if (is.call(default_expr) && length(default_expr) > 1) { - func_name <- as.character(default_expr[[1]]) - - if (identical(func_name, "c")) { - # Extract all arguments to c() - values <- character(0) - for (i in seq(2, length(default_expr))) { - arg <- default_expr[[i]] - # Only handle character literals - if (is.character(arg)) { - values <- c(values, arg) - } else if (is.call(arg) && as.character(arg[[1]]) %in% c("I")) { - # Handle I("value") - if (length(arg) > 1 && is.character(arg[[2]])) { - values <- c(values, arg[[2]]) - } - } - } - if (length(values) > 0) { - return(values) + literal_call <- function(expr, name) { + if (!is.call(expr)) return(FALSE) + head <- expr[[1L]] + if (is.symbol(head)) return(identical(as.character(head), name)) + # Namespace-qualified calls have a language object as their head. + # Recognize only the base helpers and never evaluate other callees. + is.call(head) && length(head) == 3L && + identical(head[[1L]], quote(`::`)) && + identical(head[[2L]], quote(base)) && identical(head[[3L]], as.name(name)) + } + if (literal_call(default_expr, "c") && length(default_expr) > 1L) { + # Language objects use pairlists; repeated [[i]] also walks/copies the + # argument chain. Convert once and collect literals before flattening. + args <- as.list(default_expr)[-1L] + values <- vector("list", length(args)) + for (i in seq_along(args)) { + if (identical(args[[i]], quote(expr = ))) next + arg <- args[[i]] + if (is.character(arg)) { + values[[i]] <- arg + } else if (literal_call(arg, "I") && length(arg) == 2L && + !identical(arg[[2L]], quote(expr = )) && is.character(arg[[2L]])) { + values[[i]] <- arg[[2L]] } } + values <- unlist(values, use.names = FALSE) + if (length(values)) return(values) } # If it's a simple string, return it @@ -247,9 +251,11 @@ argument_value_completion <- function(workspace, funct, package, arg_name, token default_value <- formals_list[[arg_name]] - # Extract possible values from the default - values <- extract_default_values(default_value) - + argument_value_items(extract_default_values(default_value), funct, package, + arg_name, token, uri) +} + +argument_value_items <- function(values, funct, package, arg_name, token, uri) { if (is.null(values) || length(values) == 0) { return(list()) } @@ -264,7 +270,7 @@ argument_value_completion <- function(workspace, funct, package, arg_name, token kind = CompletionItemKind$Value, detail = paste0("value for ", arg_name), sortText = paste0(sort_prefixes$arg, value), - insertText = sprintf('"%s"', value), + insertText = encodeString(value, quote = "\""), insertTextFormat = InsertTextFormat$PlainText, data = list( type = "argument_value", @@ -300,24 +306,12 @@ arg_value_completion <- function(uri, workspace, document, point, token, funct, # Get all parameters with character vector defaults param_names <- names(formals_list) - all_completions <- list() - - for (param_name in param_names) { - values <- extract_default_values(formals_list[[param_name]]) - if (!is.null(values) && length(values) > 0) { - # Filter values that match the current token - matching_values <- values[match_with(values, token)] - if (length(matching_values) > 0) { - # Generate completions for this parameter - param_completions <- argument_value_completion( - workspace, funct, package_for_call, param_name, token, - exported_only, formals_list, uri = uri) - all_completions <- c(all_completions, param_completions) - } - } - } - - all_completions + all_completions <- lapply(seq_along(formals_list), function(i) { + argument_value_items(extract_default_values(formals_list[[i]]), + funct, package_for_call, param_names[[i]], token, uri) + }) + items <- unlist(all_completions, recursive = FALSE, use.names = FALSE) + if (length(items)) items else list() } #' Complete a function argument diff --git a/tests/testthat/test-completion-default-values.R b/tests/testthat/test-completion-default-values.R new file mode 100644 index 00000000..cb5cfd58 --- /dev/null +++ b/tests/testthat/test-completion-default-values.R @@ -0,0 +1,52 @@ +test_that("Default value extraction handles qualified and incomplete calls safely", { + expect_identical(extract_default_values(quote(c(base::I("first"), "second"))), + c("first", "second")) + expect_identical(extract_default_values(quote(base::c("first", base::I("second")))), + c("first", "second")) + expect_identical(extract_default_values(quote(c(, I(), I(, "ignored"), "last"))), "last") + expect_identical(extract_default_values(quote(c(other::I("ignored"), "last"))), "last") + expect_identical(extract_default_values(quote(c((function() "ignored")(), "last"))), "last") + expect_null(extract_default_values(quote(other::c("ignored")))) + + withr::local_options(languageserver.default_value_evaluated = FALSE) + expr <- quote(c(options(languageserver.default_value_evaluated = TRUE), "literal")) + expect_identical(extract_default_values(expr), "literal") + expect_false(getOption("languageserver.default_value_evaluated")) +}) + +test_that("Default value completion inserts strings that preserve literal values", { + expressions <- c('"a\\\"b"', '"c\\\\d"', '"line\\nend"', '"tab\\tend"', + '""', '"漢字😀"') + if (getRversion() >= "4.0.0") expressions <- c(expressions, 'r"(raw\\path"value)"') + defaults <- list(mode = str2lang(paste0("c(", paste(expressions, collapse = ", "), ")"))) + workspace <- list(get_formals = function(...) defaults) + items <- argument_value_completion(workspace, "f", NULL, "mode", "") + expect_length(items, length(expressions)) + expected <- vapply(expressions, str2lang, "", USE.NAMES = FALSE) + expect_identical(vapply(items, function(item) str2lang(item$insertText), ""), expected) + expect_identical(vapply(items, `[[`, "", "label"), expected) + expect_true(all(vapply(items, `[[`, numeric(1L), "insertTextFormat") == InsertTextFormat$PlainText)) +}) + +test_that("Argument value completion extracts each default once and keeps parameter order", { + defaults <- alist(first = c("one", "two"), missing = , last = base::c("three", base::I("four"))) + calls <- 0L + workspace <- list(guess_namespace = function(...) "example", get_formals = function(...) { + calls <<- calls + 1L + defaults + }) + items <- arg_value_completion(NULL, workspace, NULL, NULL, "", "f") + expect_equal(calls, 1L) + expect_identical(vapply(items, `[[`, "", "label"), c("one", "two", "three", "four")) + expect_identical(vapply(items, function(item) item$data$argument, ""), + c("first", "first", "last", "last")) + + extractor <- mockery::mock(c("one", "two"), NULL, c("three", "four")) + complete <- arg_value_completion + mockery::stub(complete, "extract_default_values", extractor) + expect_identical(complete(NULL, workspace, NULL, NULL, "", "f"), items) + expect_equal(length(mockery::mock_args(extractor)), length(defaults)) + + workspace$get_formals <- function(...) alist(value = 1L) + expect_identical(arg_value_completion(NULL, workspace, NULL, NULL, "", "f"), list()) +}) From ccc08f681ed299ba555e1474c9f3e6ef465f0273 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:24:33 +0800 Subject: [PATCH 02/16] Keep document state current across workspace moves and closes --- R/handlers-textsync.R | 11 +- R/languageserver.R | 8 ++ inst/benchmarks/text-changes.R | 56 ++++++++++ tests/testthat/test-workspace-lifecycle.R | 123 ++++++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 inst/benchmarks/text-changes.R diff --git a/R/handlers-textsync.R b/R/handlers-textsync.R index 4b3a7754..2ba73267 100644 --- a/R/handlers-textsync.R +++ b/R/handlers-textsync.R @@ -103,9 +103,9 @@ text_document_did_change <- function(self, params) { workspace$documents$set(uri, doc) } doc$did_open() - if (is.function(workspace$index$invalidate)) workspace$index$invalidate(uri) # The accepted background parse updates definitions and source edges. # Parsing a shallow index here would block every keystroke a second time. + # text_sync's await_parse also invalidates any pending disk snapshot. self$text_sync( uri, document = doc, @@ -202,8 +202,13 @@ text_document_did_close <- function(self, params) { workspace$index$remove(uri) } self$prune_index_documents(workspace) - # do not remove document in package - } else if (!(is_package(workspace$root) && is_from_workspace)) { + } else if (is_package(workspace$root) && is_from_workspace) { + # Package providers still retain full documents with indexing off. + # Closing the editor discards unsaved content in this path too. + if (is.function(self$load_index_document)) { + self$load_index_document(workspace, uri, reload = TRUE) + } + } else { diagnostics_callback(self, uri, NULL, list()) workspace$documents$remove(uri) workspace$diagnostics_globals_cache <- NULL diff --git a/R/languageserver.R b/R/languageserver.R index 153e3ca6..d012d9f4 100644 --- a/R/languageserver.R +++ b/R/languageserver.R @@ -130,6 +130,14 @@ LanguageServer <- R6::R6Class("LanguageServer", resolve_task(self, uri, doc, normalize_package_request(parsed$packages), namespace_packages = parsed$member_data$packages)) } + if (isTRUE(doc$is_open) && !is.null(self$diagnostics_task_manager)) { + self$text_sync(uri, doc, run_diagnostics = TRUE) + } + } else if (!is.null(self$parse_task_manager)) { + # Parse retries belong to the workspace that queued them. A + # moved buffer needs its own task even when its old worker is + # still running, so a late failure cannot strand providers. + self$text_sync(uri, doc, run_diagnostics = isTRUE(doc$is_open), parse = TRUE) } }, finalize = function() { diff --git a/inst/benchmarks/text-changes.R b/inst/benchmarks/text-changes.R new file mode 100644 index 00000000..56db2918 --- /dev/null +++ b/inst/benchmarks/text-changes.R @@ -0,0 +1,56 @@ +# R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/text-changes.R results.csv 500 10000 3 +# Measure notification handling with a pending disk-index backlog. Parse task +# creation, worker execution, transport, and editor rendering are excluded. +args <- commandArgs(trailingOnly = TRUE) +output <- if (length(args)) args[[1L]] else "text-changes-performance.csv" +edits <- if (length(args) > 1L) as.integer(args[[2L]]) else 500L +files <- if (length(args) > 2L) as.integer(args[[3L]]) else 10000L +passes <- if (length(args) > 3L) as.integer(args[[4L]]) else 3L +stopifnot(!is.na(edits), edits > 0L, !is.na(files), files >= 0L, + !is.na(passes), passes > 0L) +benchmark <- new.env(parent = asNamespace("languageserver")) +benchmark$output <- output +benchmark$edits <- edits +benchmark$files <- files +benchmark$passes <- passes +evalq({ + lsp_settings$set("index_mode", "lazy") + lsp_settings$set("index_persistent_cache", FALSE) + lsp_settings$set("diagnostics", FALSE) + root <- tempdir() + uri <- path_to_uri(file.path(root, "main.R")) + rows <- lapply(seq_len(passes), function(pass) { + workspace <- new.env(parent = emptyenv()) + workspace$documents <- collections::dict() + workspace$index <- WorkspaceIndex$new(root) + pending <- path_to_uri(file.path(root, paste0(seq_len(files), ".R"))) + workspace$index$pending <- pending + document <- Document$new(uri, language = "r", version = 1L, content = "x") + document$did_open() + workspace$documents$set(uri, document) + self <- new.env(parent = emptyenv()) + self$get_workspace <- function(...) workspace + self$pending_replies <- collections::dict() + # Isolate text changes and index invalidation from parse task creation. + self$text_sync <- function(uri, ...) workspace$index$await_parse(uri) + params <- list(textDocument = list(uri = uri, version = 2L), + contentChanges = list(list(range = range(position(0L, 0L), position(0L, 1L)), text = "y"))) + gc() + started <- proc.time() + for (edit in seq_len(edits)) { + params$textDocument$version <- edit + 1L + params$contentChanges[[1L]]$text <- if (edit %% 2L) "y" else "x" + text_document_did_change(self, params) + } + elapsed <- proc.time() - started + stopifnot(identical(workspace$index$pending, pending), + identical(document$version, edits + 1L), + identical(document$content, if (edits %% 2L) "y" else "x")) + data.frame(pass = pass, pending_files = files, edits = edits, + elapsed_ms = 1000 * elapsed[[3L]], cpu_ms = 1000 * sum(elapsed[1:2]), + ms_per_edit = 1000 * elapsed[[3L]] / edits) + }) + results <- do.call(rbind, rows) + utils::write.csv(results, output, row.names = FALSE) + print(results, row.names = FALSE) +}, benchmark) diff --git a/tests/testthat/test-workspace-lifecycle.R b/tests/testthat/test-workspace-lifecycle.R index a00e3a62..6cf87ecb 100644 --- a/tests/testthat/test-workspace-lifecycle.R +++ b/tests/testthat/test-workspace-lifecycle.R @@ -356,6 +356,129 @@ test_that("stale parse failures and deferred retries leave newer work intact", { } }) +test_that("workspace transfers restart pending buffer parses in their destination", { + lifecycle_settings(index_persistent_cache = FALSE, diagnostics = FALSE) + for (move in c("add", "remove")) { + root <- withr::local_tempdir() + nested <- file.path(root, "nested") + dir.create(nested) + uri <- path_to_uri(file.path(nested, "main.R")) + server <- LifecycleServer$new(root) + if (move == "remove") server$add_workspace(path_to_uri(nested)) + source <- server$get_workspace(uri) + document <- Document$new(uri, version = 1L, content = "buffer_value <- 1") + document$did_open() + source$documents$set(uri, document) + server$text_sync(uri, document, parse = TRUE) + text_document_document_symbol(server, 1L, list(textDocument = list(uri = uri))) + # Simulate a running task, which the test manager has already dispatched. + old <- server$parse_task_manager$tasks$pop(uri) + + if (move == "add") server$add_workspace(path_to_uri(nested)) + else server$remove_workspace(path_to_uri(nested)) + destination <- server$get_workspace(uri) + replacement <- server$parse_task_manager$tasks$get(uri, NULL) + expect_false(identical(source, destination), info = move) + expect_true(inherits(replacement, "Task"), info = move) + expect_true(destination$index$document_pending(uri) || + !destination$index$complete(), info = move) + + # The worker for the old workspace may fail after the move. Its + # rejected retry must not leave the new workspace without a parse. + old$fail(simpleError("old workspace worker failed")) + server$process_parse_retries() + expect_identical(server$parse_task_manager$tasks$get(uri), replacement, info = move) + server$parse_task_manager$finish_one() + server$process_pending_replies(time_budget_ms = Inf) + expect_identical(names(document$parse_data$definitions), "buffer_value", info = move) + expect_length(server$messages, 1L) + expect_identical(server$messages[[1L]]$id, 1L) + expect_null(server$messages[[1L]]$error) + } +}) + +test_that("workspace transfers refresh diagnostics while reusing accepted parses", { + lifecycle_settings(index_persistent_cache = FALSE, diagnostics = TRUE, diagnostics_cache_ttl = 0) + for (move in c("add", "remove")) { + root <- withr::local_tempdir() + nested <- file.path(root, "nested") + dir.create(nested) + uri <- path_to_uri(file.path(nested, "main.R")) + server <- LifecycleServer$new(root) + if (move == "remove") server$add_workspace(path_to_uri(nested)) + source <- server$get_workspace(uri) + document <- lifecycle_parse(source, uri, "buffer_value = 1") + parsed <- document$parse_data + generation <- document$parse_generation + if (move == "add") server$add_workspace(path_to_uri(nested)) + else server$remove_workspace(path_to_uri(nested)) + + expect_identical(document$parse_data, parsed, info = move) + expect_identical(document$parse_generation, generation, info = move) + expect_false(server$parse_task_manager$has_work(), info = move) + expect_true(server$diagnostics_task_manager$tasks$has(uri), info = move) + request <- server$diagnostics_task_manager$tasks$get(uri)$.__enclos_env__$private$args$request + expect_identical(request$content, document$content, info = move) + expect_identical(request$workspace_root, server$get_workspace(uri)$root, info = move) + } +}) + +test_that("didChange invalidates the pending disk snapshot once per edit", { + lifecycle_settings(index_persistent_cache = FALSE, diagnostics = FALSE) + root <- withr::local_tempdir() + uri <- path_to_uri(file.path(root, "main.R")) + server <- LifecycleServer$new(root) + workspace <- server$get_workspace(uri) + CountingIndex <- R6::R6Class("CountingIndex", inherit = WorkspaceIndex, + public = list(invalidations = 0L, invalidate = function(uri) { + self$invalidations <- self$invalidations + 1L + super$invalidate(uri) + })) + workspace$index <- CountingIndex$new(root) + document <- lifecycle_parse(workspace, uri, "before <- 1") + index <- workspace$index + index$invalidations <- 0L + text_document_did_change(server, list(textDocument = list(uri = uri, version = 2L), + contentChanges = list(list(text = "after <- 2")))) + expect_identical(index$invalidations, 1L) + expect_true(server$parse_task_manager$tasks$has(uri)) + expect_false(index$complete()) + server$parse_task_manager$finish_one() + expect_identical(names(document$parse_data$definitions), "after") + expect_true(index$complete()) +}) + +test_that("closing package buffers restores disk content with indexing disabled", { + lifecycle_settings(index_mode = "off", index_persistent_cache = FALSE, diagnostics = FALSE) + root <- withr::local_tempdir() + dir.create(file.path(root, "R")) + writeLines("Package: legacyclosefixture", file.path(root, "DESCRIPTION")) + uri <- path_to_uri(file.path(root, "R", "helper.R")) + writeLines("saved <- function(x) x", path_from_uri(uri)) + server <- LifecycleServer$new(root) + workspace <- server$get_workspace(uri) + old <- lifecycle_parse(workspace, uri, "unsaved <- function(y) y") + server$pending_replies$set(uri, list()) + expect_false(workspace$index$enabled) + + text_document_did_close(server, list(textDocument = list(uri = uri))) + expect_true(workspace$documents$has(uri)) + current <- workspace$documents$get(uri) + expect_false(current$is_open) + expect_identical(current$content, "saved <- function(x) x") + expect_true(server$parse_task_manager$tasks$has(uri)) + server$parse_task_manager$finish_one() + expect_identical(names(current$parse_data$definitions), "saved") + expect_false(identical(current, old)) + + # A discarded new file has no disk version to retain in the package. + missing <- path_to_uri(file.path(root, "R", "new.R")) + lifecycle_parse(workspace, missing, "new_value <- 1") + server$pending_replies$set(missing, list()) + text_document_did_close(server, list(textDocument = list(uri = missing))) + expect_false(workspace$documents$has(missing)) +}) + test_that("closed package files load preindexed source dependencies outside R", { lifecycle_settings(index_persistent_cache = FALSE, diagnostics = FALSE) root <- withr::local_tempdir() From 0a1abefa1e79e054a6d125de169122c952029d8f Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:26:33 +0800 Subject: [PATCH 03/16] Preserve quoted symbol identity and scope in navigation and rename --- R/code_lens.R | 5 ++- R/definition.R | 4 +- R/highlight.R | 2 +- R/navigation.R | 5 ++- R/references.R | 55 +++++++++++++++++++++--- R/rename.R | 20 +++++---- tests/testthat/test-code-lens.R | 15 +++++++ tests/testthat/test-rename-quoted.R | 66 +++++++++++++++++++++++++++++ 8 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 tests/testthat/test-rename-quoted.R diff --git a/R/code_lens.R b/R/code_lens.R index 3a8d8a1c..bfc0dce2 100644 --- a/R/code_lens.R +++ b/R/code_lens.R @@ -12,7 +12,8 @@ current_parse_data <- function(uri, workspace, document) { #' Find calls to a workspace function without resolving every symbol #' @noRd -function_call_locations <- function(workspace, symbol, context_uri = NULL) { +function_call_locations <- function(workspace, symbol, context_uri = NULL, + definition_key = paste0("global:", symbol)) { token_quote <- xml_single_quote(symbol) locations <- list() @@ -23,7 +24,7 @@ function_call_locations <- function(workspace, symbol, context_uri = NULL) { parse_data <- workspace$get_parse_data(doc_uri) indexed <- parse_data$reference_index if (!is.null(indexed)) { - selected <- reference_indices(indexed, symbol) + selected <- reference_indices(indexed, symbol, definition_key) selected <- selected[indexed$token[selected] == "SYMBOL_FUNCTION_CALL" & !indexed$qualified_call[selected]] if (length(selected)) { diff --git a/R/definition.R b/R/definition.R index 910dffa4..ef84647a 100644 --- a/R/definition.R +++ b/R/definition.R @@ -20,11 +20,11 @@ definition_reply <- function(id, uri, workspace, document, point, return(Response$new(id)) } - token_result <- document$detect_token(point) + parse_data <- workspace$get_parse_data(uri) + token_result <- reference_token_at(document, parse_data, point) resolved <- FALSE result <- NULL - parse_data <- workspace$get_parse_data(uri) syntax <- parse_data$syntax_data if (token_result$accessor == "" && !is.null(syntax)) { row <- point$row + 1L diff --git a/R/highlight.R b/R/highlight.R index aa55ad14..5d3855a4 100644 --- a/R/highlight.R +++ b/R/highlight.R @@ -17,7 +17,7 @@ document_highlight_reply <- function(id, uri, workspace, document, point) { result <- NULL parse_data <- workspace$get_parse_data(uri) index <- parse_data$reference_index - detected <- document$detect_token(point) + detected <- reference_token_at(document, parse_data, point) if (!is.null(index) && nzchar(detected$token)) { token_point <- list( row = detected$range$start$row, diff --git a/R/navigation.R b/R/navigation.R index 02101e0d..dee84b7e 100644 --- a/R/navigation.R +++ b/R/navigation.R @@ -67,8 +67,8 @@ navigation_parse_data <- function(syntax) { make_groups <- function(rows, names) { list2env(split(rows, names), hash = TRUE, parent = emptyenv()) } - records$by_name <- make_groups(seq_along(records$node), syntax$text[records$name]) - scope_names <- paste(records$scope, syntax$text[records$scope_name], sep = "\r") + records$by_name <- make_groups(seq_along(records$node), reference_symbol_names(syntax$text[records$name])) + scope_names <- paste(records$scope, reference_symbol_names(syntax$text[records$scope_name]), sep = "\r") records$by_scope_name <- make_groups(seq_along(records$scope), scope_names) records$cache <- new.env(hash = TRUE, parent = emptyenv()) records$bytes <- sum(vapply(list(records$by_name, records$by_scope_name), function(groups) { @@ -93,6 +93,7 @@ navigation_token <- function(parse_data, line, col) { #' @noRd navigation_definitions <- function(parse_data, line, col, name, mode = "definition", token = 0L, last = FALSE) { + name <- reference_symbol_names(name) syntax <- parse_data$syntax_data index <- parse_data$navigation_data if (is.null(syntax) || is.null(index)) return(NULL) diff --git a/R/references.R b/R/references.R index a21f5f9d..e3a943e2 100644 --- a/R/references.R +++ b/R/references.R @@ -1,5 +1,49 @@ references_xpath <- "//*[(self::SYMBOL or self::SYMBOL_FUNCTION_CALL or self::SYMBOL_FORMALS) and text() = '{token_quote}']" +#' Decode quoted symbol spellings without evaluating document code +#' @noRd +reference_symbol_names <- function(names) { + quoted <- which(!is.na(names) & startsWith(names, "`")) + if (!length(quoted)) return(names) + spellings <- unique(names[quoted]) + decoded <- vapply(spellings, function(name) { + symbol <- tryCatch(str2lang(name), error = function(e) NULL) + if (is.symbol(symbol)) as.character(symbol) else name + }, character(1L), USE.NAMES = FALSE) + names[quoted] <- decoded[match(names[quoted], spellings)] + names +} + +#' Preserve the complete range and name of a backtick-quoted identifier +#' @noRd +reference_token_at <- function(document, parse_data, point) { + detected <- document$detect_token(point) + syntax <- parse_data$syntax_data + if (!is.null(syntax)) { + row <- navigation_token(parse_data, point$row + 1L, point$col + 1L) + if (!row || !syntax$token[[row]] %in% + c("SYMBOL", "SYMBOL_FUNCTION_CALL", "SYMBOL_FORMALS")) return(detected) + text <- syntax$text[[row]] + if (!startsWith(text, "`")) return(detected) + first <- list(row = syntax$line1[[row]] - 1L, col = syntax$col1[[row]] - 1L) + last <- list(row = syntax$line2[[row]] - 1L, col = syntax$col2[[row]]) + } else { + xdoc <- parse_data_xml(parse_data) + if (is.null(xdoc)) return(detected) + token <- xdoc_find_token(xdoc, point$row + 1L, point$col + 1L) + if (!xml_name(token) %in% c("SYMBOL", "SYMBOL_FUNCTION_CALL", "SYMBOL_FORMALS")) return(detected) + text <- xml_text(token) + if (!startsWith(text, "`")) return(detected) + first <- list(row = as.integer(xml_attr(token, "line1")) - 1L, + col = as.integer(xml_attr(token, "col1")) - 1L) + last <- list(row = as.integer(xml_attr(token, "line2")) - 1L, + col = as.integer(xml_attr(token, "col2"))) + } + detected$token <- reference_symbol_names(text) + detected$range <- list(start = first, end = last) + detected +} + #' Classify bare magrittr stages as calls without changing source positions #' @noRd reference_call_tokens <- function(data, syntax = NULL) { @@ -93,6 +137,7 @@ reference_parse_data <- function(data, content, completion_data, uri, ), c("variable", "function", "formal") ) + definitions$name <- reference_symbol_names(definitions$name) global_lines <- vapply(global_definitions, function(definition) { as.integer(definition$range$start$line + 1L) @@ -101,7 +146,7 @@ reference_parse_data <- function(data, content, completion_data, uri, definitions$line == unname(global_lines[definitions$name]) local <- which(!is_global) - names <- data$text[rows] + names <- reference_symbol_names(data$text[rows]) definition_keys <- paste0("global:", names) definition_kinds <- rep("global", length(rows)) if (length(local)) { @@ -232,12 +277,12 @@ references_reply <- function(id, uri, workspace, document, point, return(Response$new(id, result = list())) } - token <- document$detect_token(point) + parse_data <- workspace$get_parse_data(uri) + token <- reference_token_at(document, parse_data, point) if (!nzchar(token$token) || !check_scope(uri, document, point)) { return(Response$new(id, result = list())) } defn <- definition_reply(NULL, uri, workspace, document, point) - token_quote <- xml_single_quote(token$token) logger$info("references_reply: ", list( uri = uri, @@ -248,7 +293,6 @@ references_reply <- function(id, uri, workspace, document, point, result <- list() if (length(defn$result)) { - parse_data <- workspace$get_parse_data(uri) token_point <- list( row = token$range$start$row, col = token$range$start$col @@ -284,7 +328,8 @@ references_reply <- function(id, uri, workspace, document, point, return(list()) } - symbols <- xml_find_all(xdoc, glue(references_xpath, token_quote = token_quote)) + symbols <- xml_find_all(xdoc, "//SYMBOL | //SYMBOL_FUNCTION_CALL | //SYMBOL_FORMALS") + symbols <- symbols[reference_symbol_names(xml_text(symbols)) == token$token] if (length(symbols) == 0) { return(list()) } diff --git a/R/rename.R b/R/rename.R index 17b910f7..50a84975 100644 --- a/R/rename.R +++ b/R/rename.R @@ -7,7 +7,7 @@ prepare_rename_reply <- function(id, uri, workspace, document, point) { )) } - token <- document$detect_token(point) + token <- reference_token_at(document, workspace$get_parse_data(uri), point) if (!nzchar(token$token) || !check_scope(uri, document, point)) { return(ResponseErrorMessage$new(id, "RequestCancelled", "Cannot rename the symbol")) } @@ -41,19 +41,21 @@ prepare_rename_reply <- function(id, uri, workspace, document, point) { #' @noRd rename_reply <- function(id, uri, workspace, document, point, newName) { + if (!is.character(newName) || length(newName) != 1L || is.na(newName) || !nzchar(newName)) { + return(ResponseErrorMessage$new(id, "InvalidParams", "The new name must be a non-empty string")) + } if (!check_r_region(document, point)) { return(Response$new(id)) } refs <- references_reply(NULL, uri, workspace, document, point) - result <- list() - - for (ref in refs$result) { - result[[ref$uri]] <- c(result[[ref$uri]], list(text_edit( - range = ref$range, - new_text = newName - ))) - } + uris <- vapply(refs$result, function(ref) ref$uri, character(1L)) + groups <- split(seq_along(uris), factor(uris, levels = unique(uris))) + replacement <- quote_completion_name(newName) + result <- lapply(groups, function(indices) { + lapply(refs$result[indices], function(ref) text_edit( + range = ref$range, new_text = replacement)) + }) logger$info("rename_reply: ", result) diff --git a/tests/testthat/test-code-lens.R b/tests/testthat/test-code-lens.R index 5998b76d..7075c2eb 100644 --- a/tests/testthat/test-code-lens.R +++ b/tests/testthat/test-code-lens.R @@ -37,6 +37,21 @@ test_that("code lenses lazily resolve R function call counts", { ))) }) +test_that("code lens counts exclude calls to same-named local functions", { + fixture <- provider_fixture(c( + "foo <- function() 1", + "foo()", + "bar <- function() { foo <- function() 2; foo() }", + "baz <- function(foo) foo()", + "foo()" + )) + locations <- function_call_locations(fixture$workspace, "foo", fixture$uri) + expect_equal(vapply(locations, function(item) item$range$start$line, integer(1L)), c(1L, 4L)) + lens <- code_lens_reply(1L, fixture$uri, fixture$workspace, fixture$document)$result[[1L]] + resolved <- code_lens_resolve_reply(2L, fixture$workspace, lens)$result + expect_equal(resolved$command$title, "2 calls") +}) + test_that("code lenses work through the language server after incremental edits", { skip_on_cran() client <- language_client(capabilities = list(textDocument = list( diff --git a/tests/testthat/test-rename-quoted.R b/tests/testthat/test-rename-quoted.R new file mode 100644 index 00000000..0d30783e --- /dev/null +++ b/tests/testthat/test-rename-quoted.R @@ -0,0 +1,66 @@ +test_that("quoted identifiers retain their complete ranges and lexical scope", { + fixture <- provider_fixture(c( + "`foo bar` <- 1", + "`foo bar`", + "f <- function(`foo bar`) `foo bar`" + )) + point <- list(row = 1L, col = 5L) + expected <- range(position(1L, 0L), position(1L, 9L)) + for (indexed in c(TRUE, FALSE)) { + if (!indexed) fixture$document$parse_data$reference_index <- NULL + prepared <- prepare_rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, point) + expect_equal(prepared$result, unclass(expected)) + references <- references_reply(1L, fixture$uri, fixture$workspace, fixture$document, point)$result + expect_length(references, 2L) + expect_equal(vapply(references, function(ref) ref$range$start$line, numeric(1L)), c(0L, 1L)) + expect_equal(references[[2L]]$range, expected) + } +}) + +test_that("equivalent quoted spellings share navigation and reference identities", { + fixture <- provider_fixture(c("`value` <- 1", "value", "`value`")) + for (point in list(list(row = 1L, col = 2L), list(row = 2L, col = 3L))) { + references <- references_reply(1L, fixture$uri, fixture$workspace, fixture$document, point)$result + expect_length(references, 3L) + highlights <- document_highlight_reply(1L, fixture$uri, fixture$workspace, fixture$document, point)$result + expect_length(highlights, 3L) + } +}) + +test_that("renaming quotes names and escapes their literal characters", { + fixture <- provider_fixture(c("value <- 1", "value")) + point <- list(row = 1L, col = 2L) + for (name in c("new_value", "new name", "if", "+", "a`b", "a\\b", intToUtf8(0x03b1))) { + reply <- rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, point, name) + edits <- reply$result$changes[[fixture$uri]] + expect_length(edits, 2L) + replacement <- edits[[1L]]$newText + expect_identical(as.character(str2lang(replacement)), name) + expect_identical(edits[[2L]]$newText, replacement) + expression <- str2lang(paste0(replacement, " <- 1")) + expect_identical(as.character(expression[[2L]]), name) + } + for (name in list("", NA_character_, NULL, c("a", "b"), 1L)) { + reply <- rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, point, name) + expect_equal(reply$error$code, ErrorCodes$InvalidParams) + expect_null(reply$result) + } +}) + +test_that("rename groups edits once while preserving URI and occurrence order", { + fixture <- provider_fixture(c("value <- 1", "value")) + refs <- lapply(seq_len(1000L), function(i) location( + if (i %% 3L == 0L) "file:///second.R" else fixture$uri, + range(position(i, 0L), position(i, 5L)) + )) + mockery::stub(rename_reply, "references_reply", function(...) Response$new(1L, result = refs)) + changes <- rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, + list(row = 1L, col = 2L), "renamed")$result$changes + expect_identical(names(changes), c(fixture$uri, "file:///second.R")) + for (uri in names(changes)) { + selected <- refs[vapply(refs, function(ref) identical(ref$uri, uri), logical(1L))] + expect_identical(changes[[uri]], lapply(selected, function(ref) { + text_edit(ref$range, "renamed") + })) + } +}) From c1c0d06aec7b802a090e0e18070d1eb804592f8e Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:30:29 +0800 Subject: [PATCH 04/16] Decode class literals and preserve multiline quoted reference ranges --- R/class_index.R | 24 +++++- R/code_lens.R | 6 +- R/highlight.R | 2 +- R/references.R | 6 ++ R/rename.R | 5 +- R/type_hierarchy.R | 73 +++++++++---------- tests/testthat/test-rename-quoted.R | 22 +++++- tests/testthat/test-type-hierarchy-literals.R | 73 +++++++++++++++++++ 8 files changed, 159 insertions(+), 52 deletions(-) create mode 100644 tests/testthat/test-type-hierarchy-literals.R diff --git a/R/class_index.R b/R/class_index.R index df462ea3..46236e03 100644 --- a/R/class_index.R +++ b/R/class_index.R @@ -5,6 +5,22 @@ class_index_key <- function(type, name) paste(type, name, sep = "\r") class_xml_first <- function(node, xpath) xml_find_first(node, xpath, ns = character()) class_xml_all <- function(node, xpath) xml_find_all(node, xpath, ns = character()) +#' Decode inert class names and member names using R literal syntax +#' @noRd +class_token_text <- function(text) { + quoted <- grep("^[\"'`]|^[rR][\"']", text) + if (!length(quoted)) return(text) + spellings <- unique(text[quoted]) + decoded <- vapply(spellings, function(value) { + literal <- tryCatch(str2lang(value), error = function(e) NULL) + if (is.symbol(literal)) as.character(literal) + else if (is.character(literal) && length(literal) == 1L) literal + else value + }, character(1L), USE.NAMES = FALSE) + text[quoted] <- decoded[match(text[quoted], spellings)] + text +} + # Match inert argument indices, retaining each value's original XML location. # This gives class providers R's named/positional matching without evaluating # declarations, and keeps piped names and placeholders at their source ranges. @@ -68,7 +84,7 @@ class_xml_index <- function(xdoc) { functions <- c("R6Class", "setClass", "setRefClass", "setMethod") result <- stats::setNames(vector("list", length(functions)), functions) unquote <- function(node) { - if (length(node)) gsub('["\047`]', "", xml_text(node)) else character() + if (length(node)) class_token_text(xml_text(node)) else character() } for (fun in functions) { calls <- class_xml_all(xdoc, paste0("//SYMBOL_FUNCTION_CALL[text() = '", fun, @@ -84,7 +100,7 @@ class_xml_index <- function(xdoc) { param <- class_xml_argument(call, "inherit") symbol <- class_xml_first(param, "./SYMBOL | ./expr//SYMBOL") if (length(symbol)) { - parents[[i]] <- xml_text(symbol) + parents[[i]] <- class_token_text(xml_text(symbol)) } else { string <- class_xml_first(param, "./STR_CONST | ./expr//STR_CONST") parents[[i]] <- unquote(if (length(string)) string else param) @@ -92,12 +108,12 @@ class_xml_index <- function(xdoc) { } else { names[[i]] <- if (fun == "setMethod") { strings <- class_xml_all(class_xml_argument(call, "signature"), ".//STR_CONST") - gsub('["\047`]', "", xml_text(strings)) + class_token_text(xml_text(strings)) } else { unquote(class_xml_name(call)) } param <- class_xml_argument(call, "contains") - parents[[i]] <- gsub('["\047`]', "", xml_text(class_xml_all(param, ".//STR_CONST"))) + parents[[i]] <- class_token_text(xml_text(class_xml_all(param, ".//STR_CONST"))) } } groups <- function(values) { diff --git a/R/code_lens.R b/R/code_lens.R index bfc0dce2..f5d5f1b2 100644 --- a/R/code_lens.R +++ b/R/code_lens.R @@ -3,8 +3,8 @@ current_parse_data <- function(uri, workspace, document) { parse_data <- workspace$get_parse_data(uri) if (is.null(parse_data) || - (!is.null(parse_data$version) && - !isTRUE(parse_data$version == document$version))) { + (!is.null(parse_data$version) && + !isTRUE(parse_data$version == document$version))) { return(NULL) } parse_data @@ -45,7 +45,7 @@ function_call_locations <- function(workspace, symbol, context_uri = NULL, "not(preceding-sibling::NS_GET or ", "preceding-sibling::NS_GET_INT)]" ), - token_quote = token_quote) + token_quote = token_quote) ) if (!length(nodes)) next diff --git a/R/highlight.R b/R/highlight.R index 5d3855a4..28ae7c07 100644 --- a/R/highlight.R +++ b/R/highlight.R @@ -93,7 +93,7 @@ document_highlight_reply <- function(id, uri, workspace, document, point) { tokens <- xml_find_all(xdoc, xpath) } } else if (token_name %in% c( - "SYMBOL_PACKAGE", "FUNCTION", "NUM_CONST", "STR_CONST")) { + "SYMBOL_PACKAGE", "FUNCTION", "NUM_CONST", "STR_CONST")) { # Compare text directly: XML entities do not escape quotes # inside XPath string literals (for example, 'a string'). tokens <- xml_find_all(xdoc, paste0("//", token_name)) diff --git a/R/references.R b/R/references.R index e3a943e2..e3987814 100644 --- a/R/references.R +++ b/R/references.R @@ -197,6 +197,12 @@ reference_parse_data <- function(data, content, completion_data, uri, units <- syntax_unit_columns(content, data$line1[rows], cols, end_cols, non_ascii_lines) cols <- units$start end_cols <- units$end + multiline <- which(data$line1[rows] != data$line2[rows]) + if (length(multiline)) { + ending <- data$col2[rows[multiline]] + end_cols[multiline] <- syntax_unit_columns(content, data$line2[rows[multiline]], + ending, ending, non_ascii_lines)$end + } package_rows <- which(data$token == "SYMBOL_PACKAGE") package_match <- match(data$parent[rows], data$parent[package_rows]) diff --git a/R/rename.R b/R/rename.R index 50a84975..81d6ded5 100644 --- a/R/rename.R +++ b/R/rename.R @@ -53,8 +53,9 @@ rename_reply <- function(id, uri, workspace, document, point, newName) { groups <- split(seq_along(uris), factor(uris, levels = unique(uris))) replacement <- quote_completion_name(newName) result <- lapply(groups, function(indices) { - lapply(refs$result[indices], function(ref) text_edit( - range = ref$range, new_text = replacement)) + lapply(refs$result[indices], function(ref) { + text_edit(range = ref$range, new_text = replacement) + }) }) logger$info("rename_reply: ", result) diff --git a/R/type_hierarchy.R b/R/type_hierarchy.R index 1f0ba812..52393f5e 100644 --- a/R/type_hierarchy.R +++ b/R/type_hierarchy.R @@ -140,13 +140,7 @@ detect_type_definition <- function(uri, workspace, document, point, token_text) token_name <- xml_name(token) - token_value <- token_text - if (!nzchar(token_value)) { - token_value <- xml_text(token) - } - if (token_name == "STR_CONST") { - token_value <- gsub('["\'`]', "", token_value) - } + token_value <- class_token_text(xml_text(token)) # Only process SYMBOL, SYMBOL_FUNCTION_CALL, or STR_CONST if (!(token_name %in% c("SYMBOL", "SYMBOL_FUNCTION_CALL", "STR_CONST"))) { @@ -165,9 +159,9 @@ detect_type_definition <- function(uri, workspace, document, point, token_text) ".//LEFT_ASSIGN/preceding-sibling::expr[1]/SYMBOL | .//EQ_ASSIGN/preceding-sibling::expr[1]/SYMBOL") class_name_value <- NULL if (length(class_str)) { - class_name_value <- gsub('["\'`]', "", xml_text(class_str)) + class_name_value <- class_token_text(xml_text(class_str)) } else if (length(class_sym)) { - class_name_value <- xml_text(class_sym) + class_name_value <- class_token_text(xml_text(class_sym)) } if (!is.null(class_name_value)) { @@ -254,7 +248,7 @@ detect_r6class <- function(scopes, token_text, document, uri) { detect_class_literal <- function(scopes, token_text, document, uri, fun, type) { for (call in class_xml_calls(scopes, fun, token_text)) { node <- class_xml_name(call) - if (!length(node) || gsub('["\047`]', "", xml_text(node)) != token_text) next + if (!length(node) || class_token_text(xml_text(node)) != token_text) next range_info <- get_element_range(document, node) if (!is.null(range_info)) return(list( name = token_text, uri = uri, range = range_info, classType = type)) @@ -411,9 +405,9 @@ find_r6_supertypes <- function(doc, xdoc, class_name, uri) { "ancestor::expr[LEFT_ASSIGN or EQ_ASSIGN][1]/expr[1]/SYMBOL[1]") class_name_value <- NULL if (length(class_str)) { - class_name_value <- gsub('["\'`]', "", xml_text(class_str)) + class_name_value <- class_token_text(xml_text(class_str)) } else if (length(class_symbol)) { - class_name_value <- xml_text(class_symbol) + class_name_value <- class_token_text(xml_text(class_symbol)) } if (is.null(class_name_value) || class_name_value != class_name) next @@ -423,13 +417,13 @@ find_r6_supertypes <- function(doc, xdoc, class_name, uri) { # Extract class name from SYMBOL or STR_CONST within the expr inherit_symbol <- class_xml_first(inherit_param, "./SYMBOL | ./expr//SYMBOL") if (length(inherit_symbol)) { - inherit_name <- xml_text(inherit_symbol) + inherit_name <- class_token_text(xml_text(inherit_symbol)) } else { inherit_str <- class_xml_first(inherit_param, "./STR_CONST | ./expr//STR_CONST") if (length(inherit_str)) { - inherit_name <- gsub('["\'`]', "", xml_text(inherit_str)) + inherit_name <- class_token_text(xml_text(inherit_str)) } else { - inherit_name <- gsub('["\'`]', "", xml_text(inherit_param)) + inherit_name <- class_token_text(xml_text(inherit_param)) } } @@ -475,7 +469,7 @@ find_s4_supertypes <- function(doc, xdoc, class_name, uri) { first_str <- class_xml_name(setclass_call) if (!length(first_str)) next - call_class_name <- gsub('["\'`]', "", xml_text(first_str)) + call_class_name <- class_token_text(xml_text(first_str)) if (call_class_name != class_name) next @@ -486,7 +480,7 @@ find_s4_supertypes <- function(doc, xdoc, class_name, uri) { # Could contain one or more class names as strings parent_strs <- class_xml_all(contains_param, ".//STR_CONST") for (parent_str in parent_strs) { - parent_name <- gsub('["\'`]', "", xml_text(parent_str)) + parent_name <- class_token_text(xml_text(parent_str)) range_info <- get_element_range(doc, parent_str) if (!is.null(range_info)) { supertypes <- c(supertypes, list(list( @@ -517,7 +511,7 @@ find_refclass_supertypes <- function(doc, xdoc, class_name, uri) { first_str <- class_xml_name(setrefclass_call) if (!length(first_str)) next - call_class_name <- gsub('["\'`]', "", xml_text(first_str)) + call_class_name <- class_token_text(xml_text(first_str)) if (call_class_name != class_name) next @@ -527,7 +521,7 @@ find_refclass_supertypes <- function(doc, xdoc, class_name, uri) { if (length(contains_param) > 0) { parent_strs <- class_xml_all(contains_param, ".//STR_CONST") for (parent_str in parent_strs) { - parent_name <- gsub('["\'`]', "", xml_text(parent_str)) + parent_name <- class_token_text(xml_text(parent_str)) range_info <- get_element_range(doc, parent_str) if (!is.null(range_info)) { supertypes <- c(supertypes, list(list( @@ -647,13 +641,13 @@ find_r6_subtypes <- function(doc, xdoc, parent_name, uri) { # Extract class name from SYMBOL or STR_CONST within the expr inherit_symbol_name <- class_xml_first(inherit_param, "./SYMBOL | ./expr//SYMBOL") if (length(inherit_symbol_name)) { - inherit_name <- xml_text(inherit_symbol_name) + inherit_name <- class_token_text(xml_text(inherit_symbol_name)) } else { inherit_str <- class_xml_first(inherit_param, "./STR_CONST | ./expr//STR_CONST") if (length(inherit_str)) { - inherit_name <- gsub('["\'`]', "", xml_text(inherit_str)) + inherit_name <- class_token_text(xml_text(inherit_str)) } else { - inherit_name <- gsub('["\'`]', "", xml_text(inherit_param)) + inherit_name <- class_token_text(xml_text(inherit_param)) } } @@ -662,14 +656,14 @@ find_r6_subtypes <- function(doc, xdoc, parent_name, uri) { # Extract the actual class name class_str <- class_xml_name(class_def) if (length(class_str)) { - class_name <- gsub('["\'`]', "", xml_text(class_str)) + class_name <- class_token_text(xml_text(class_str)) range_info <- get_element_range(doc, class_str) } else { # Try to find from LHS of assignment class_sym <- class_xml_first(class_def, "ancestor::expr/expr[1]/SYMBOL") if (length(class_sym)) { - class_name <- xml_text(class_sym) + class_name <- class_token_text(xml_text(class_sym)) range_info <- get_element_range(doc, class_sym) } else { next @@ -721,7 +715,7 @@ find_s4_subtypes <- function(doc, xdoc, parent_name, uri) { parent_strs <- class_xml_all(contains_param, ".//STR_CONST") found_parent <- FALSE for (parent_str in parent_strs) { - parent_text <- gsub('["\'`]', "", xml_text(parent_str)) + parent_text <- class_token_text(xml_text(parent_str)) if (parent_text == parent_name) { found_parent <- TRUE break @@ -733,7 +727,7 @@ find_s4_subtypes <- function(doc, xdoc, parent_name, uri) { class_str <- class_xml_name(setclass_call) if (length(class_str)) { - class_name <- gsub('["\'`]', "", xml_text(class_str)) + class_name <- class_token_text(xml_text(class_str)) range_info <- get_element_range(doc, class_str) if (!is.null(range_info)) { subtypes <- c(subtypes, list(list( @@ -768,7 +762,7 @@ find_refclass_subtypes <- function(doc, xdoc, parent_name, uri) { parent_strs <- class_xml_all(contains_param, ".//STR_CONST") found_parent <- FALSE for (parent_str in parent_strs) { - parent_text <- gsub('["\'`]', "", xml_text(parent_str)) + parent_text <- class_token_text(xml_text(parent_str)) if (parent_text == parent_name) { found_parent <- TRUE break @@ -780,7 +774,7 @@ find_refclass_subtypes <- function(doc, xdoc, parent_name, uri) { class_str <- class_xml_name(setrefclass_call) if (length(class_str)) { - class_name <- gsub('["\'`]', "", xml_text(class_str)) + class_name <- class_token_text(xml_text(class_str)) range_info <- get_element_range(doc, class_str) if (!is.null(range_info)) { subtypes <- c(subtypes, list(list( @@ -885,7 +879,7 @@ extract_r6_members <- function(document, xdoc, def) { class_str <- class_xml_name(class_def) class_name_value <- NULL if (length(class_str)) { - class_name_value <- gsub('["\047`]', "", xml_text(class_str)) + class_name_value <- class_token_text(xml_text(class_str)) } # If not found or doesn't match, try to get from left side of assignment @@ -898,7 +892,7 @@ extract_r6_members <- function(document, xdoc, def) { class_symbol <- class_xml_first(assign_expr, "./expr[1]/SYMBOL[1]") if (length(class_symbol)) { - class_name_value <- xml_text(class_symbol) + class_name_value <- class_token_text(xml_text(class_symbol)) } } } @@ -928,11 +922,14 @@ extract_r6_list_members <- function(document, list_node, access_modifier) { # Find SYMBOL_SUB elements that have EQ_SUB as immediate next sibling # This indicates name = value patterns - all_symbol_subs <- class_xml_all(list_node, ".//SYMBOL_SUB[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]]") + all_symbol_subs <- class_xml_all(list_node, paste( + ".//SYMBOL_SUB[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]]", + ".//STR_CONST[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]]", + sep = " | ")) # Process all SYMBOL_SUB elements and try to find their values for (symbol_sub in all_symbol_subs) { - member_name <- xml_text(symbol_sub) + member_name <- class_token_text(xml_text(symbol_sub)) # Skip if this is a nested list definition (public/private/active) if (member_name %in% c("public", "private", "active", "inherit", "lock_objects", @@ -1018,7 +1015,7 @@ extract_s4_members <- function(document, xdoc, def) { first_str <- class_xml_name(setclass_call) if (!length(first_str)) next - call_class_name <- gsub('["\047`]', "", xml_text(first_str)) + call_class_name <- class_token_text(xml_text(first_str)) if (call_class_name != class_name) next @@ -1032,7 +1029,7 @@ extract_s4_members <- function(document, xdoc, def) { ".//SYMBOL_SUB[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]] | .//STR_CONST[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]]") for (slot_name_node in slot_names) { slot_name_text <- xml_text(slot_name_node) - slot_name <- gsub('["\047`]', "", slot_name_text) + slot_name <- class_token_text(slot_name_text) slot_range <- get_element_range(document, slot_name_node) if (!is.null(slot_range) && nzchar(slot_name)) { @@ -1063,7 +1060,7 @@ extract_s4_members <- function(document, xdoc, def) { found_class <- FALSE method_name <- NULL for (i in seq_along(class_strs)) { - str_value <- gsub('["\047`]', "", xml_text(class_strs[[i]])) + str_value <- class_token_text(xml_text(class_strs[[i]])) if (i == 1) { method_name <- str_value } else if (str_value == class_name) { @@ -1104,7 +1101,7 @@ extract_refclass_members <- function(document, xdoc, def) { first_str <- class_xml_name(setrefclass_call) if (!length(first_str)) next - call_class_name <- gsub('["\047`]', "", xml_text(first_str)) + call_class_name <- class_token_text(xml_text(first_str)) if (call_class_name != class_name) next @@ -1115,7 +1112,7 @@ extract_refclass_members <- function(document, xdoc, def) { field_names <- class_xml_all(fields_node, "./SYMBOL_SUB[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]] | ./STR_CONST[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]]") for (field_name_node in field_names) { - field_name <- gsub('["\047`]', "", xml_text(field_name_node)) + field_name <- class_token_text(xml_text(field_name_node)) field_range <- get_element_range(document, field_name_node) if (!is.null(field_range)) { @@ -1140,7 +1137,7 @@ extract_refclass_members <- function(document, xdoc, def) { method_names <- class_xml_all(methods_node, "./SYMBOL_SUB[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]] | ./STR_CONST[following-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]]") for (method_name_node in method_names) { - method_name <- gsub('["\047`]', "", xml_text(method_name_node)) + method_name <- class_token_text(xml_text(method_name_node)) method_range <- get_element_range(document, method_name_node) if (!is.null(method_range)) { diff --git a/tests/testthat/test-rename-quoted.R b/tests/testthat/test-rename-quoted.R index 0d30783e..44ec738e 100644 --- a/tests/testthat/test-rename-quoted.R +++ b/tests/testthat/test-rename-quoted.R @@ -27,6 +27,20 @@ test_that("equivalent quoted spellings share navigation and reference identities } }) +test_that("multiline quoted identifiers convert each endpoint on its own line", { + text <- paste0(intToUtf8(0x1f600), "b`") + fixture <- provider_fixture(c("`a", paste0(text, " <- 1"), "`a", text)) + point <- list(row = 2L, col = 1L) + expected <- list(range(position(0L, 0L), position(1L, 4L)), + range(position(2L, 0L), position(3L, 4L))) + refs <- references_reply(1L, fixture$uri, fixture$workspace, fixture$document, point)$result + expect_equal(lapply(refs, function(ref) ref$range), expected) + prepared <- prepare_rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, point) + expect_equal(prepared$result, unclass(expected[[2L]])) + renamed <- rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, point, "renamed") + expect_equal(lapply(renamed$result$changes[[fixture$uri]], function(edit) edit$range), expected) +}) + test_that("renaming quotes names and escapes their literal characters", { fixture <- provider_fixture(c("value <- 1", "value")) point <- list(row = 1L, col = 2L) @@ -49,10 +63,10 @@ test_that("renaming quotes names and escapes their literal characters", { test_that("rename groups edits once while preserving URI and occurrence order", { fixture <- provider_fixture(c("value <- 1", "value")) - refs <- lapply(seq_len(1000L), function(i) location( - if (i %% 3L == 0L) "file:///second.R" else fixture$uri, - range(position(i, 0L), position(i, 5L)) - )) + refs <- lapply(seq_len(1000L), function(i) { + location(if (i %% 3L == 0L) "file:///second.R" else fixture$uri, + range(position(i, 0L), position(i, 5L))) + }) mockery::stub(rename_reply, "references_reply", function(...) Response$new(1L, result = refs)) changes <- rename_reply(1L, fixture$uri, fixture$workspace, fixture$document, list(row = 1L, col = 2L), "renamed")$result$changes diff --git a/tests/testthat/test-type-hierarchy-literals.R b/tests/testthat/test-type-hierarchy-literals.R new file mode 100644 index 00000000..1ff1a067 --- /dev/null +++ b/tests/testthat/test-type-hierarchy-literals.R @@ -0,0 +1,73 @@ +test_that("class hierarchy preserves raw strings and escaped class and slot names", { + parent <- 'Pa`rent"name' + child <- "Ch\u03b9ld" + slot <- 'a"b' + quoted_slot <- "odd`slot" + code <- c( + sprintf('setClass(r"(%s)")', parent), + sprintf('setClass("Ch\\u03b9ld", contains = r"(%s)", slots = c(%s = "character", %s = "numeric"))', + parent, encodeString(slot, quote = '"'), encodeString(quoted_slot, quote = "`")) + ) + fixture <- provider_fixture(code) + parsed <- fixture$document$parse_data + for (indexed in c(TRUE, FALSE)) { + xdoc <- parse_data_xml(parsed) + if (!indexed) attr(xdoc, "class_index") <- NULL + detected <- detect_type_definition(fixture$uri, fixture$workspace, fixture$document, + list(row = 0L, col = 15L), "rent") + expect_identical(detected$name, parent) + expect_identical(vapply(find_s4_supertypes(fixture$document, xdoc, child, fixture$uri), + function(item) item$name, character(1L)), parent) + expect_identical(vapply(find_s4_subtypes(fixture$document, xdoc, parent, fixture$uri), + function(item) item$name, character(1L)), child) + members <- extract_s4_members(fixture$document, xdoc, list(name = child, type = "S4")) + expect_identical(vapply(members, function(member) member$name, character(1L)), c(slot, quoted_slot)) + } + restored <- unserialize(serialize(parsed$class_data, NULL)) + expect_identical(restored$supertypes[[class_index_key("S4", child)]][[1L]]$name, parent) + expect_identical(restored$subtypes[[class_index_key("S4", parent)]][[1L]]$name, child) +}) + +test_that("R6 hierarchy and members preserve quoted generator and member names", { + generator <- "Parent generator" + parent <- "Pa`rent" + child <- 'Child"name' + field <- "odd`field" + method <- 'run"method' + code <- c( + sprintf('%s <- R6::R6Class(r"(%s)")', encodeString(generator, quote = "`"), parent), + sprintf('Child <- R6::R6Class(r"(%s)", inherit = %s, public = list(%s = 1, %s = function() NULL))', + child, encodeString(generator, quote = "`"), encodeString(field, quote = "`"), + encodeString(method, quote = '"')) + ) + fixture <- provider_fixture(code) + parsed <- fixture$document$parse_data + xdoc <- parse_data_xml(parsed) + supers <- find_r6_supertypes(fixture$document, xdoc, child, fixture$uri) + expect_identical(supers[[1L]]$name, generator) + subs <- find_r6_subtypes(fixture$document, xdoc, generator, fixture$uri) + expect_identical(subs[[1L]]$name, child) + members <- extract_r6_members(fixture$document, xdoc, list(name = "Child", type = "R6")) + expect_identical(vapply(members, function(member) member$name, character(1L)), c(field, method)) + expect_identical(vapply(members, function(member) member$kind, numeric(1L)), + c(SymbolKind$Field, SymbolKind$Method)) +}) + +test_that("static class literal decoding never evaluates expressions", { + text <- c('r"(a`b\"c)"', '"a\\u0062"', "`a\\`b`", "plain", 'stop("must not run")', + "r'(foo)'", "R'[bar]'") + expect_identical(class_token_text(text), c('a`b"c', "ab", "a`b", "plain", text[[5L]], "foo", "bar")) +}) + +test_that("single-quoted raw class literals retain hierarchy identities", { + fixture <- provider_fixture(c("setClass(r'(Parent)')", + "setClass(R'[Child]', contains = r'(Parent)')")) + parsed <- fixture$document$parse_data + xdoc <- parse_data_xml(parsed) + detected <- detect_type_definition(fixture$uri, fixture$workspace, fixture$document, + list(row = 0L, col = 13L), "Parent") + expect_identical(detected$name, "Parent") + expect_identical(find_s4_supertypes(fixture$document, xdoc, "Child", fixture$uri)[[1L]]$name, "Parent") + expect_identical(find_s4_subtypes(fixture$document, xdoc, "Parent", fixture$uri)[[1L]]$name, "Child") + expect_identical(parsed$class_data$supertypes[[class_index_key("S4", "Child")]][[1L]]$name, "Parent") +}) From e2e4af16f9a69eb9679861592d677db383aa2c51 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:31:33 +0800 Subject: [PATCH 05/16] Reuse CLI diagnostic indexes and document responsiveness benchmarks --- NEWS.md | 16 +++++++ R/backend-cli.R | 18 +++++-- R/completion.R | 1 + inst/benchmarks/README.md | 41 ++++++++++++++++ inst/benchmarks/cli-diagnostics.R | 28 +++++++++++ inst/benchmarks/default-values.R | 47 +++++++++++++++++++ inst/benchmarks/rename-edits.R | 39 +++++++++++++++ tests/testthat/test-backends.R | 34 ++++++++++++++ .../testthat/test-completion-default-values.R | 2 + 9 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 inst/benchmarks/cli-diagnostics.R create mode 100644 inst/benchmarks/default-values.R create mode 100644 inst/benchmarks/rename-edits.R diff --git a/NEWS.md b/NEWS.md index b1b90131..6baeacb3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,21 @@ # languageserver 0.3.20 +- Keep argument-value completion working with namespace-qualified default + helpers and missing arguments. Escape suggested string literals so quotes, + backslashes and newlines retain their original values. +- Preserve quoted symbol identity and complete ranges in definitions, + references, highlights and rename, including multiline names with Unicode. + Quote nonsyntactic replacement names and exclude shadowed local calls from + workspace function CodeLens counts. +- Decode raw strings and escaped class, slot and member names in type hierarchy + and class member symbols without evaluating declarations. +- Restart pending parses and refresh diagnostics when documents move between + workspace folders. Restore saved package content after closing an unsaved + buffer with workspace indexing disabled. +- Reduce repeated copying during default extraction and rename edit creation, + invalidate pending disk snapshots once per edit, and share one UTF-8 byte + index across each CLI diagnostic batch. + - Preserve member completion for Polars conversions such as `dt <- as_polars_df(iris)` and for `pl$` after calls using the converted frame. Prepare bounded S3 method return summaries with their owning namespace. diff --git a/R/backend-cli.R b/R/backend-cli.R index b7c0a486..da8e3f70 100644 --- a/R/backend-cli.R +++ b/R/backend-cli.R @@ -58,17 +58,24 @@ backend_cli_failure <- function(request, result) { result$status, if (nzchar(detail)) paste0(": ", detail) else ".")) } -#' Convert half-open UTF-8 byte offsets against the submitted buffer +#' Index the submitted buffer once for all CLI diagnostics #' @noRd -backend_byte_range <- function(content, offsets) { +backend_byte_index <- function(content) { bytes <- charToRaw(enc2utf8(paste(content, collapse = "\n"))) + list(bytes = bytes, starts = c(0L, which(bytes == as.raw(10L)))) +} + +#' Convert half-open UTF-8 byte offsets against the submitted buffer +#' @noRd +backend_byte_range <- function(content, offsets, index = backend_byte_index(content)) { + bytes <- index$bytes offsets <- unlist(offsets, use.names = FALSE) if (!is.numeric(offsets) || length(offsets) != 2L || anyNA(offsets) || any(!is.finite(offsets)) || any(offsets != floor(offsets)) || offsets[[1L]] < 0 || offsets[[2L]] < offsets[[1L]] || offsets[[2L]] > length(bytes)) { backend_error("CLI diagnostic contains an invalid byte range.") } - starts <- c(0L, which(bytes == as.raw(10L))) + starts <- index$starts positions <- lapply(offsets, function(offset) { line <- findInterval(offset, starts) - 1L start <- starts[[line + 1L]] @@ -111,11 +118,12 @@ jarl_diagnostics <- function(request) { !is.list(output$diagnostics) || !is.list(output$errors)) { backend_error("Jarl JSON must contain diagnostics and errors arrays.") } + index <- if (length(output$diagnostics) || length(output$errors)) backend_byte_index(request$content) diagnostics <- lapply(output$diagnostics, function(item) { if (!is.list(item) || !backend_string(item$message$body) || !backend_string(item$message$name)) { backend_error("Jarl returned an invalid diagnostic.") } - list(range = backend_byte_range(request$content, item$range), + list(range = backend_byte_range(request$content, item$range, index), severity = DiagnosticSeverity$Warning, source = "jarl", code = item$message$name, message = item$message$body, codeDescription = list(href = paste0("https://jarl.etiennebacher.com/rules/", item$message$name))) @@ -130,7 +138,7 @@ jarl_diagnostics <- function(request) { for (syntax in error$syntax_errors) { if (!backend_string(syntax$message)) backend_error("Jarl returned an invalid syntax error.") diagnostics[[length(diagnostics) + 1L]] <- list( - range = backend_byte_range(request$content, syntax$range), + range = backend_byte_range(request$content, syntax$range, index), severity = DiagnosticSeverity$Error, source = "jarl", message = syntax$message) } } diff --git a/R/completion.R b/R/completion.R index baf0ea8d..3598de2b 100644 --- a/R/completion.R +++ b/R/completion.R @@ -306,6 +306,7 @@ arg_value_completion <- function(uri, workspace, document, point, token, funct, # Get all parameters with character vector defaults param_names <- names(formals_list) + if (is.null(param_names)) return(list()) all_completions <- lapply(seq_along(formals_list), function(i) { argument_value_items(extract_default_values(formals_list[[i]]), funct, package_for_call, param_names[[i]], token, uri) diff --git a/inst/benchmarks/README.md b/inst/benchmarks/README.md index a36973b0..02c62e01 100644 --- a/inst/benchmarks/README.md +++ b/inst/benchmarks/README.md @@ -5,6 +5,47 @@ latency of interactive requests. The baseline is commit `9618b5b`. Measurements below were taken locally with R 4.6.1 on macOS arm64; they are reproducible workloads, not timing assertions in the test suite. +## Usage review workloads + +The usage review adds four workloads that check their results before timing +the affected operation. Run installed revisions serially in separate libraries: + +```sh +R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/default-values.R defaults.csv 10000 3 +R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/rename-edits.R rename.csv 10000 3 +R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/cli-diagnostics.R diagnostics.csv 500 5000 3 +R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/text-changes.R changes.csv 500 10000 3 +``` + +Default extraction converts the language argument chain once and collects +literals before flattening. Rename groups references once by URI before +building edits. CLI diagnostics reuse one index of the submitted UTF-8 bytes; +empty diagnostic batches skip indexing. Text changes invalidate pending disk +snapshots once before their background parse. + +The usage-review baseline is `cf116dd`. Three local passes with R 4.6.1 on +macOS arm64 produced these medians: + +| Operation | Before (ms) | After (ms) | +| --- | ---: | ---: | +| Extract 10,000 literal defaults | 850 | 1 | +| Complete 2,000 defaults | 88 | 41 | +| Construct 10,000 rename edits | 213 | 31 | +| Convert 500 diagnostics across 5,000 lines | 646 | 15 | +| Incremental edit with 10,000 pending disk files | 1.904 | 1.578 | + +Recorded R allocations fell from 1.20 GB to 520 KB for default extraction and +from 400.52 MB to 612 KB for rename construction. These are total allocations +within each operation, not retained memory or server startup costs. + +These scripts isolate extraction, edit construction, diagnostic range +conversion and notification handling respectively. They exclude worker +execution, reference resolution, document parsing, IPC and editor rendering. +The text-change workload also excludes parse task creation. Default and rename +scripts report R allocations when memory profiling is supported. Local timings +vary with hardware and load; regression tests assert behavior without elapsed +thresholds. + ## Member metadata cache Run `R_LIBS=/path/to/library Rscript inst/benchmarks/member-metadata.R results.csv 10` diff --git a/inst/benchmarks/cli-diagnostics.R b/inst/benchmarks/cli-diagnostics.R new file mode 100644 index 00000000..6eee5648 --- /dev/null +++ b/inst/benchmarks/cli-diagnostics.R @@ -0,0 +1,28 @@ +# R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/cli-diagnostics.R results.csv 500 5000 3 +# Measure conversion of one diagnostic batch, including one byte-index build. +# CLI execution, document parsing, transport, and editor rendering are excluded. +args <- commandArgs(TRUE) +output <- if (length(args)) args[[1L]] else "cli-diagnostics.csv" +diagnostics <- if (length(args) > 1L) as.integer(args[[2L]]) else 500L +lines <- if (length(args) > 2L) as.integer(args[[3L]]) else 5000L +passes <- if (length(args) > 3L) as.integer(args[[4L]]) else 3L +stopifnot(!is.na(diagnostics), diagnostics > 0L, !is.na(lines), lines > 0L, + !is.na(passes), passes > 0L) +ns <- asNamespace("languageserver") +content <- rep(paste0(strrep("x", 80L), "\U0001f600"), lines) +offsets <- rep(list(c(0L, 80L)), diagnostics) +convert <- if ("index" %in% names(formals(ns$backend_byte_range))) function() { + index <- ns$backend_byte_index(content) + lapply(offsets, function(offset) ns$backend_byte_range(content, offset, index)) +} else function() lapply(offsets, function(offset) ns$backend_byte_range(content, offset)) +expected <- rep(list(ns$range(ns$position(0L, 0L), ns$position(0L, 80L))), diagnostics) +stopifnot(identical(convert(), expected)) +invisible(convert()) +times <- vapply(seq_len(passes), function(pass) { + gc() + unname(system.time(invisible(convert()))[["elapsed"]]) * 1000 +}, numeric(1L)) +results <- data.frame(diagnostics = diagnostics, lines = lines, + median_ms = stats::median(times), min_ms = min(times), max_ms = max(times), passes = passes) +utils::write.csv(results, output, row.names = FALSE) +print(results, row.names = FALSE) diff --git a/inst/benchmarks/default-values.R b/inst/benchmarks/default-values.R new file mode 100644 index 00000000..0e9d8e72 --- /dev/null +++ b/inst/benchmarks/default-values.R @@ -0,0 +1,47 @@ +# Run against an installed revision with its library selected by R_LIBS: +# Rscript --vanilla inst/benchmarks/default-values.R results.csv 10000 3 +# Measures extraction from one wide default and completion across many formals. +args <- commandArgs(trailingOnly = TRUE) +output <- if (length(args)) args[[1L]] else "default-values-performance.csv" +size <- if (length(args) > 1L) as.integer(args[[2L]]) else 10000L +samples <- if (length(args) > 2L) as.integer(args[[3L]]) else 3L +stopifnot(!is.na(size), size > 0L, !is.na(samples), samples > 0L) + +extract <- getFromNamespace("extract_default_values", "languageserver") +complete <- getFromNamespace("arg_value_completion", "languageserver") +labels <- sprintf("value-%05d", seq_len(size)) +default <- as.call(c(list(as.name("c")), as.list(labels))) +defaults <- stats::setNames(rep(list(quote(c("first", "last"))), min(size, 2000L)), + paste0("parameter", seq_len(min(size, 2000L)))) +workspace <- list(guess_namespace = function(...) "example", get_formals = function(...) defaults) +stopifnot(identical(extract(default), labels)) +items <- complete(NULL, workspace, NULL, NULL, "", "example") +stopifnot(length(items) == 2L * length(defaults), + identical(vapply(items, `[[`, "", "label"), rep(c("first", "last"), length(defaults)))) + +measure <- function(operation, run, count) { + times <- replicate(samples, { + gc() + system.time(invisible(run()))[["elapsed"]] * 1000 + }) + allocation <- NA_real_ + if (capabilities("profmem")) { + path <- tempfile() + on.exit(unlink(path), add = TRUE) + Rprofmem(path) + tryCatch(invisible(run()), finally = Rprofmem(NULL)) + bytes <- suppressWarnings(as.numeric(sub(" .*", "", readLines(path)))) + allocation <- sum(bytes, na.rm = TRUE) + } + data.frame(operation = operation, count = count, median_ms = median(times), + min_ms = min(times), max_ms = max(times), samples = samples, + allocated_bytes = allocation) +} +results <- rbind( + measure("extract_wide_default", function() extract(default), size), + measure("complete_many_defaults", function() complete(NULL, workspace, NULL, NULL, "", "example"), + length(defaults))) +write.csv(results, output, row.names = FALSE) +print(results, row.names = FALSE) +cat(sprintf("R %s; %s; languageserver %s\n", getRversion(), R.version$platform, + as.character(utils::packageVersion("languageserver")))) diff --git a/inst/benchmarks/rename-edits.R b/inst/benchmarks/rename-edits.R new file mode 100644 index 00000000..f7bcaf27 --- /dev/null +++ b/inst/benchmarks/rename-edits.R @@ -0,0 +1,39 @@ +# R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/rename-edits.R results.csv 10000 3 +# Isolate edit construction from reference resolution, parsing, and transport. +args <- commandArgs(TRUE) +output <- if (length(args)) args[[1L]] else "rename-edits.csv" +size <- if (length(args) > 1L) as.integer(args[[2L]]) else 10000L +passes <- if (length(args) > 2L) as.integer(args[[3L]]) else 3L +stopifnot(!is.na(size), size > 0L, !is.na(passes), passes > 0L) +ns <- asNamespace("languageserver") +uri <- "file:///rename-benchmark.R" +refs <- lapply(seq_len(size), function(i) { + ns$location(uri, ns$range(ns$position(i, 0L), ns$position(i, 5L))) +}) +benchmark <- new.env(parent = ns) +benchmark$references_reply <- function(...) list(result = refs) +rename <- ns$rename_reply +environment(rename) <- benchmark +document <- ns$Document$new(uri, content = "value") +run <- function() rename(1L, uri, NULL, document, list(row = 0L, col = 2L), "renamed")$result +expected <- list(changes = stats::setNames(list(lapply(refs, function(ref) { + ns$text_edit(ref$range, "renamed") +})), uri)) +stopifnot(identical(run(), expected)) +times <- replicate(passes, { + gc() + unname(system.time(invisible(run()))[["elapsed"]]) * 1000 +}) +allocation <- NA_real_ +if (capabilities("profmem")) { + path <- tempfile() + Rprofmem(path) + tryCatch(invisible(run()), finally = Rprofmem(NULL)) + bytes <- suppressWarnings(as.numeric(sub(" .*", "", readLines(path)))) + allocation <- sum(bytes, na.rm = TRUE) + unlink(path) +} +results <- data.frame(edits = size, median_ms = stats::median(times), + min_ms = min(times), max_ms = max(times), passes = passes, allocated_bytes = allocation) +utils::write.csv(results, output, row.names = FALSE) +print(results, row.names = FALSE) diff --git a/tests/testthat/test-backends.R b/tests/testthat/test-backends.R index 1f38972c..1026acba 100644 --- a/tests/testthat/test-backends.R +++ b/tests/testthat/test-backends.R @@ -388,10 +388,44 @@ test_that("configuration watches are registered only for capable clients", { test_that("CLI byte ranges use exact UTF-8 input and UTF-16 positions", { content <- c('"\U0001f600\u00e9"', "value") + index <- backend_byte_index(content) expect_equal(backend_byte_range(content, c(1, 7)), range(position(0L, 1L), position(0L, 4L))) expect_equal(backend_byte_range(content, c(9, 14)), range(position(1L, 0L), position(1L, 5L))) + expect_equal(backend_byte_range(content, c(1, 7), index), range(position(0L, 1L), position(0L, 4L))) + expect_equal(backend_byte_range(content, c(9, 14), index), range(position(1L, 0L), position(1L, 5L))) + expect_equal(backend_byte_range(content, c(8, 9), index), range(position(0L, 5L), position(1L, 0L))) + expect_equal(backend_byte_range(content, c(14, 14), index), range(position(1L, 5L), position(1L, 5L))) expect_error(backend_byte_range(content, c(2, 5)), "splits a UTF-8") expect_error(backend_byte_range(content, c(0, 99)), "invalid byte range") + expect_error(backend_byte_range(content, c(2, 5), index), "splits a UTF-8") + expect_error(backend_byte_range(content, c(0, 99), index), "invalid byte range") +}) + +test_that("Jarl shares a byte index across findings and syntax errors", { + fixture <- backend_fixture(c('"\U0001f600"', "x")) + local_backend_settings(diagnostics_backend = "jarl", backend_options = list()) + request <- backend_request("diagnostics", fixture$uri, fixture$document, fixture$workspace) + result <- list(status = 255L, stderr = "", stdout = paste0( + '{"diagnostics":[{"range":[1,5],"message":{"name":"rule","body":"finding"}},', + '{"range":[7,8],"message":{"name":"rule","body":"second"}}],', + '"errors":[{"syntax_errors":[{"range":[0,1],"message":"syntax"}]}]}')) + calls <- 0L + native_index <- backend_byte_index + testthat::local_mocked_bindings(backend_cli_run = function(...) result, + backend_byte_index = function(content) { + calls <<- calls + 1L + native_index(content) + }, .package = "languageserver") + diagnostics <- jarl_diagnostics(request) + expect_identical(calls, 1L) + expect_equal(lapply(diagnostics, "[[", "range"), list( + range(position(0L, 1L), position(0L, 3L)), + range(position(1L, 0L), position(1L, 1L)), + range(position(0L, 0L), position(0L, 1L)))) + calls <- 0L + result <- list(status = 0L, stderr = "", stdout = '{"diagnostics":[],"errors":[]}') + expect_identical(jarl_diagnostics(request), list()) + expect_identical(calls, 0L) }) test_that("Jarl parses findings, syntax errors and excluded buffers distinctly", { diff --git a/tests/testthat/test-completion-default-values.R b/tests/testthat/test-completion-default-values.R index cb5cfd58..eb67fc19 100644 --- a/tests/testthat/test-completion-default-values.R +++ b/tests/testthat/test-completion-default-values.R @@ -49,4 +49,6 @@ test_that("Argument value completion extracts each default once and keeps parame workspace$get_formals <- function(...) alist(value = 1L) expect_identical(arg_value_completion(NULL, workspace, NULL, NULL, "", "f"), list()) + workspace$get_formals <- function(...) list("unnamed") + expect_identical(arg_value_completion(NULL, workspace, NULL, NULL, "", "f"), list()) }) From c33e7cae70213196a71cbecda59d94c5adfc94c6 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:40:37 +0800 Subject: [PATCH 06/16] Preserve request ID types across cancellation and asynchronous work --- R/formatting.R | 6 +-- R/handlers-general.R | 6 +-- R/languagebase.R | 4 +- R/languageserver.R | 2 +- R/protocol.R | 9 ++++ tests/testthat/helper-utils.R | 2 +- tests/testthat/test-formatting-background.R | 25 ++++++++++- tests/testthat/test-handlers-general.R | 48 +++++++++++++++++++++ tests/testthat/test-languagebase.R | 26 +++++++++-- tests/testthat/test-workspace-progress.R | 2 +- 10 files changed, 114 insertions(+), 16 deletions(-) diff --git a/R/formatting.R b/R/formatting.R index 017614cb..10e98630 100644 --- a/R/formatting.R +++ b/R/formatting.R @@ -19,7 +19,7 @@ format_document <- function(snapshot, options, range = NULL, ranges = NULL, #' 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) + key <- request_id_key(request$id) if (!identical(self$formatting_requests$get(key, NULL), request)) { return(invisible(NULL)) } @@ -74,7 +74,7 @@ enqueue_formatting <- function(self, id, uri, document, options, callback = function(result) formatting_callback(self, request, result), error = function(error) formatting_callback(self, request, error = error), track_descendants = !is.null(invocation$backend$executable)) - key <- as.character(id) + key <- request_id_key(id) self$formatting_requests$set(key, request) self$formatting_task_manager$add_task(key, task) invisible(NULL) @@ -88,7 +88,7 @@ cancel_formatting_requests <- function(self, uri = NULL, id = 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 + (!is.null(id) && !identical(key, request_id_key(id)))) next self$formatting_requests$remove(key) self$formatting_task_manager$cancel(key) self$deliver(ResponseErrorMessage$new( diff --git a/R/handlers-general.R b/R/handlers-general.R index 7d6b924e..6911a57d 100644 --- a/R/handlers-general.R +++ b/R/handlers-general.R @@ -102,10 +102,10 @@ on_exit <- function(self, params) { #' @noRd cancel_request <- function(self, params) { request_id <- params$id + request_key <- request_id_key(request_id) if (!is.null(request_id)) cancel_formatting_requests(self, id = request_id) if (!is.null(self$index_requests)) { - key <- paste0(if (is.character(request_id)) "string:" else "number:", request_id) - request <- self$index_requests$pop(key, NULL) + request <- self$index_requests$pop(request_key, NULL) if (!is.null(request$workspace)) request$workspace$index$prune_pending <- TRUE if (!is.null(request)) self$deliver(ResponseErrorMessage$new(request$id, "RequestCancelled", "Request cancelled by client")) } @@ -115,7 +115,7 @@ cancel_request <- function(self, params) { retained <- list() while (queue$size()) { item <- queue$pop() - if (identical(as.character(item$id), as.character(request_id))) { + if (identical(request_id_key(item$id), request_key)) { self$deliver(ResponseErrorMessage$new( item$id, "RequestCancelled", diff --git a/R/languagebase.R b/R/languagebase.R index 33d575a1..d115396b 100644 --- a/R/languagebase.R +++ b/R/languagebase.R @@ -48,7 +48,7 @@ LanguageBase <- R6::R6Class("LanguageBase", self$write_text(message$format()) if (inherits(message, "Request") && !is.null(callback)) { id <- message$id - self$request_callbacks$set(as.character(id), callback) + self$request_callbacks$set(request_id_key(id), callback) } }, @@ -233,7 +233,7 @@ LanguageBase <- R6::R6Class("LanguageBase", handle_response = function(response) { id <- response$id callback <- tryCatch( - self$request_callbacks$pop(as.character(id)), + self$request_callbacks$pop(request_id_key(id)), error = function(e) NULL ) if (is.null(response$error)) { diff --git a/R/languageserver.R b/R/languageserver.R index d012d9f4..ba3f626b 100644 --- a/R/languageserver.R +++ b/R/languageserver.R @@ -908,7 +908,7 @@ LanguageServer <- R6::R6Class("LanguageServer", if (!is.null(uri) && length(indexes)) pending <- TRUE if (!pending) return(FALSE) if (is.null(self$index_requests)) self$index_requests <- collections::ordered_dict() - key <- paste0(if (is.character(id)) "string:" else "number:", id) + key <- request_id_key(id) self$index_requests$set(key, list(id = id, method = method, params = params, indexes = indexes, workspaces = workspaces, uri = uri, workspace = if (is.null(uri)) NULL else self$get_workspace(uri), diff --git a/R/protocol.R b/R/protocol.R index 2801cdcf..d762a915 100644 --- a/R/protocol.R +++ b/R/protocol.R @@ -13,6 +13,15 @@ Message <- R6::R6Class("Message", ) ) +#' Preserve JSON-RPC ID type in request dictionaries +#' @noRd +request_id_key <- function(id) { + if (is.character(id)) return(paste0("string:", id)) + value <- as.numeric(id) + value[!is.na(value) & value == 0] <- 0 + paste0("number:", sprintf("%.17g", value)) +} + #' Request Message class #' #' Describe a request between the client and the server. diff --git a/tests/testthat/helper-utils.R b/tests/testthat/helper-utils.R index dc76c71c..75dee1e3 100644 --- a/tests/testthat/helper-utils.R +++ b/tests/testthat/helper-utils.R @@ -164,7 +164,7 @@ respond <- function(client, method, params, timeout, allow_error = FALSE, start_time <- Sys.time() remaining <- timeout request <- client$request(method, params) - id <- as.character(request$id) + id <- request_id_key(request$id) on.exit({ if (client$request_callbacks$has(id)) client$request_callbacks$remove(id) }, add = TRUE) diff --git a/tests/testthat/test-formatting-background.R b/tests/testthat/test-formatting-background.R index f2e9af70..2df147e8 100644 --- a/tests/testthat/test-formatting-background.R +++ b/tests/testthat/test-formatting-background.R @@ -62,7 +62,7 @@ formatting_fixture <- function(content = "x=1", uri = "file:///formatting.R") { } formatting_test_task <- function(fixture, id) { - fixture$manager$.__enclos_env__$private$pending_tasks$get(as.character(id)) + fixture$manager$.__enclos_env__$private$pending_tasks$get(request_id_key(id)) } formatting_wait_for_tasks <- function(fixture, timeout = 15) { @@ -129,7 +129,7 @@ test_that("formatting cancellation answers once and leaves other requests queued 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")) + cancel_request(fixture$self, list(id = 1L)) expect_length(fixture$self$deliveries, 1L) expect_equal(fixture$self$deliveries[[1L]]$id, 1L) expect_equal(fixture$self$deliveries[[1L]]$error$code, -32800L) @@ -143,6 +143,27 @@ test_that("formatting cancellation answers once and leaves other requests queued expect_false(fixture$manager$has_work()) }) +test_that("formatting preserves requests with distinct numeric and string IDs", { + fixture <- formatting_fixture() + text_document_formatting(fixture$self, 1L, fixture$params) + text_document_formatting(fixture$self, "1", fixture$params) + numeric_task <- formatting_test_task(fixture, 1L) + string_task <- formatting_test_task(fixture, "1") + expect_false(identical(numeric_task, string_task)) + expect_identical(fixture$self$formatting_requests$size(), 2L) + cancel_request(fixture$self, list(id = 1)) + expect_length(fixture$self$deliveries, 1L) + expect_identical(fixture$self$deliveries[[1L]]$id, 1L) + expect_identical(fixture$self$formatting_requests$size(), 1L) + numeric_task$.__enclos_env__$private$callback(list()) + expect_length(fixture$self$deliveries, 1L) + string_task$.__enclos_env__$private$callback(list()) + expect_length(fixture$self$deliveries, 2L) + expect_identical(fixture$self$deliveries[[2L]]$id, "1") + expect_null(fixture$self$deliveries[[2L]]$error) + expect_identical(fixture$self$formatting_requests$size(), 0L) +}) + test_that("formatting worker errors complete and remove their requests", { fixture <- formatting_fixture() text_document_formatting(fixture$self, 1L, fixture$params) diff --git a/tests/testthat/test-handlers-general.R b/tests/testthat/test-handlers-general.R index 51a3e24d..0d0e4956 100644 --- a/tests/testthat/test-handlers-general.R +++ b/tests/testthat/test-handlers-general.R @@ -14,3 +14,51 @@ test_that("shutdown responds with a null result", { expect_null(response$result) expect_match(response$to_json(), '"result":null', fixed = TRUE) }) + +test_that("request dictionary keys preserve ID type and numeric identity", { + expect_identical(request_id_key(1L), request_id_key(1)) + expect_identical(request_id_key(1000000L), request_id_key(1e6)) + expect_identical(request_id_key(0), request_id_key(-0)) + expect_false(identical(request_id_key(1), request_id_key("1"))) + expect_false(identical(request_id_key(1e15 + 1), request_id_key(1e15 + 2))) +}) + +test_that("cancelling pending providers preserves distinct string and number IDs", { + for (request_id in list(1, "1", 1e6)) { + self <- new.env(parent = emptyenv()) + self$pending_replies <- collections::dict() + self$deliveries <- list() + self$deliver <- function(message) { + self$deliveries[[length(self$deliveries) + 1L]] <- message + } + ids <- list(1L, "1", 1000000L, "1000000") + queue <- collections::queue() + for (id in ids) queue$push(list(id = id)) + self$pending_replies$set("file:///pending-ids.R", list(completion = queue)) + cancel_request(self, list(id = request_id)) + expected <- if (is.character(request_id)) 2L else if (request_id == 1) 1L else 3L + expect_length(self$deliveries, 1L) + expect_identical(self$deliveries[[1L]]$id, ids[[expected]]) + expect_identical(self$deliveries[[1L]]$error$code, ErrorCodes$RequestCancelled) + remaining <- lapply(seq_len(queue$size()), function(...) queue$pop()$id) + expect_identical(remaining, ids[-expected]) + } +}) + +test_that("index cancellation uses the same typed ID lookup as pending providers", { + self <- new.env(parent = emptyenv()) + self$pending_replies <- collections::dict() + self$index_requests <- collections::dict() + self$deliveries <- list() + self$deliver <- function(message) { + self$deliveries[[length(self$deliveries) + 1L]] <- message + } + for (id in list(1000000L, "1000000")) { + self$index_requests$set(request_id_key(id), list(id = id)) + } + cancel_request(self, list(id = 1e6)) + expect_length(self$deliveries, 1L) + expect_identical(self$deliveries[[1L]]$id, 1000000L) + expect_false(self$index_requests$has(request_id_key(1000000L))) + expect_true(self$index_requests$has(request_id_key("1000000"))) +}) diff --git a/tests/testthat/test-languagebase.R b/tests/testthat/test-languagebase.R index 26eaa66d..7da79679 100644 --- a/tests/testthat/test-languagebase.R +++ b/tests/testthat/test-languagebase.R @@ -286,7 +286,7 @@ test_that("LanguageBase delivery stores callbacks and ignores NULL", { base$deliver(request, callback) expect_length(base$writes, 1L) - expect_true(base$request_callbacks$has(as.character(request$id))) + expect_true(base$request_callbacks$has(request_id_key(request$id))) }) test_that("LanguageBase handles malformed and unknown payloads", { @@ -312,16 +312,36 @@ test_that("LanguageBase handles malformed and unknown payloads", { ))) }) +test_that("response callbacks distinguish string IDs and normalize numeric IDs", { + base <- TestLanguageBase$new() + state <- new.env(parent = emptyenv()) + state$received <- character() + base$deliver(Request$new(1L, "numeric"), function(...) state$received <- c(state$received, "numeric")) + base$deliver(Request$new("1", "string"), function(...) state$received <- c(state$received, "string")) + expect_identical(base$request_callbacks$size(), 2L) + base$handle_response(list(id = 1, result = TRUE)) + expect_identical(state$received, "numeric") + expect_identical(base$request_callbacks$size(), 1L) + base$handle_response(list(id = "1", result = TRUE)) + expect_identical(state$received, c("numeric", "string")) + expect_identical(base$request_callbacks$size(), 0L) + + base$deliver(Request$new(1000000L, "large"), function(...) state$received <- c(state$received, "large")) + base$handle_response(list(id = 1e6, result = TRUE)) + expect_identical(utils::tail(state$received, 1L), "large") + expect_identical(base$request_callbacks$size(), 0L) +}) + test_that("LanguageBase isolates callback failures for results and errors", { base <- TestLanguageBase$new() result_request <- base$request("result", list()) error_request <- base$request("error", list()) base$request_callbacks$set( - as.character(result_request$id), + request_id_key(result_request$id), function(self, result) stop("result callback failed") ) base$request_callbacks$set( - as.character(error_request$id), + request_id_key(error_request$id), function(self, result, error) stop("error callback failed") ) diff --git a/tests/testthat/test-workspace-progress.R b/tests/testthat/test-workspace-progress.R index 4d6ecfec..b8a2d544 100644 --- a/tests/testthat/test-workspace-progress.R +++ b/tests/testthat/test-workspace-progress.R @@ -18,7 +18,7 @@ WorkspaceProgressServer <- R6::R6Class("WorkspaceProgressServer", }, deliver = function(message, callback = NULL) { self$messages[[length(self$messages) + 1L]] <- message - if (!is.null(callback)) self$request_callbacks$set(as.character(message$id), callback) + if (!is.null(callback)) self$request_callbacks$set(request_id_key(message$id), callback) }, get_workspace = function(uri) { self$workspaces$values()[[1L]] From 4797c6d5a4e987e4c865ef8b33ab9b8b4d3aec00 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:40:40 +0800 Subject: [PATCH 07/16] Discover sourced files inside computed callees --- NEWS.md | 6 ++++++ src/index.c | 3 +++ tests/testthat/test-performance-index.R | 27 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/NEWS.md b/NEWS.md index 6baeacb3..02aa0786 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,11 @@ # languageserver 0.3.20 +- Keep numeric and string request IDs distinct during cancellation, background + formatting and response dispatch. Normalize equivalent numeric IDs across + their integer and double representations. +- Discover sourced files inside computed callees, including immediately invoked + anonymous functions, without evaluating project expressions. + - Keep argument-value completion working with namespace-qualified default helpers and missing arguments. Escape suggested string literals so quotes, backslashes and newlines retain their original values. diff --git a/src/index.c b/src/index.c index 6e2ba813..549ba46d 100644 --- a/src/index.c +++ b/src/index.c @@ -29,6 +29,9 @@ static void collect_source_calls(SEXP node, SEXP result, R_xlen_t *count, if (result != R_NilValue) SET_VECTOR_ELT(result, *count, node); ++*count; } + /* Computed callees can contain immediately invoked function bodies or + * other expressions. Visit them before arguments in source order. */ + collect_source_calls(CAR(node), result, count, visited); for (SEXP args = CDR(node); args != R_NilValue; args = CDR(args)) { collect_source_calls(CAR(args), result, count, visited); } diff --git a/tests/testthat/test-performance-index.R b/tests/testthat/test-performance-index.R index 0f011c16..cbe93c7b 100644 --- a/tests/testthat/test-performance-index.R +++ b/tests/testthat/test-performance-index.R @@ -159,3 +159,30 @@ test_that("unchanged buffers reuse their current parse after cache eviction", { document$set_content(1L, "changed <- 2") expect_s3_class(parse_task(self, uri, document), "Task") }) +test_that("source discovery includes computed callees without executing them", { + marker <- new.env(parent = emptyenv()) + marker$ran <- FALSE + expression <- quote((function() { + marker$ran <- TRUE + base::source("callee.R") + sys.source("callee.R", envir = environment()) + })(source("argument.R"))) + calls <- .Call("source_calls_c", expression, PACKAGE = "languageserver") + expect_identical(calls, list(quote(base::source("callee.R")), + quote(sys.source("callee.R", envir = environment())), quote(source("argument.R")))) + expect_false(marker$ran) + expect_identical(vapply(index_source_specs(expression), function(spec) spec$path, character(1L)), + c("callee.R", "callee.R", "argument.R")) + local <- quote((local(source("local.R")))()) + expect_identical(.Call("source_calls_c", local, PACKAGE = "languageserver"), + list(quote(source("local.R")))) + root <- withr::local_tempdir() + for (name in c("callee.R", "argument.R")) writeLines("helper <- 1", file.path(root, name)) + path <- file.path(root, "main.R") + content <- deparse(expression) + writeLines(content, path) + summary <- index_shallow_summary(path, content, root) + expect_identical(summary$sources, + lapply(file.path(root, c("callee.R", "argument.R")), path_to_uri)) + expect_false(marker$ran) +}) From e27e345bf4e16da4236413e26f2256ac52b42b25 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:46:30 +0800 Subject: [PATCH 08/16] Refresh workspace caches for corrected source discovery --- NEWS.md | 3 +- R/index.R | 3 +- tests/testthat/test-backends.R | 2 +- tests/testthat/test-performance-index.R | 39 +++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index 02aa0786..171691bf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,7 +4,8 @@ formatting and response dispatch. Normalize equivalent numeric IDs across their integer and double representations. - Discover sourced files inside computed callees, including immediately invoked - anonymous functions, without evaluating project expressions. + anonymous functions, without evaluating project expressions. Refresh older + workspace-index caches so unchanged files recover these dependencies. - Keep argument-value completion working with namespace-qualified default helpers and missing arguments. Escape suggested string literals so quotes, diff --git a/R/index.R b/R/index.R index 701523de..3c9b3df0 100644 --- a/R/index.R +++ b/R/index.R @@ -1,4 +1,5 @@ -index_cache_version <- 2L +# Computed callees can expose source dependencies omitted by older summaries. +index_cache_version <- 3L #' Recognize a package without recursively searching a workspace #' @noRd diff --git a/tests/testthat/test-backends.R b/tests/testthat/test-backends.R index 1026acba..756cc9ae 100644 --- a/tests/testthat/test-backends.R +++ b/tests/testthat/test-backends.R @@ -229,7 +229,7 @@ test_that("formatting cancels obsolete configuration once and respects modes", { fixture <- backend_fixture() local_backend_settings(formatting_backend = fake_backend(), backend_options = list()) enqueue_formatting(fixture$self, 1L, fixture$uri, fixture$document, list(tabSize = 2L)) - old_task <- fixture$self$formatting_task_manager$.__enclos_env__$private$pending_tasks$get("1") + old_task <- fixture$self$formatting_task_manager$.__enclos_env__$private$pending_tasks$get(request_id_key(1L)) workspace_did_change_configuration(fixture$self, list(settings = list(formatting_backend = "air"))) expect_equal(fixture$self$deliveries[[1L]]$error$code, -32800L) old_task$.__enclos_env__$private$callback(list()) diff --git a/tests/testthat/test-performance-index.R b/tests/testthat/test-performance-index.R index cbe93c7b..e770f9a5 100644 --- a/tests/testthat/test-performance-index.R +++ b/tests/testthat/test-performance-index.R @@ -186,3 +186,42 @@ test_that("source discovery includes computed callees without executing them", { lapply(file.path(root, c("callee.R", "argument.R")), path_to_uri)) expect_false(marker$ran) }) + +test_that("old persistent summaries refresh omitted computed-callee dependencies", { + settings <- list(index_mode = "full", index_persistent_cache = TRUE) + old <- stats::setNames(lapply(names(settings), lsp_settings$get), names(settings)) + withr::defer(for (name in names(old)) lsp_settings$set(name, old[[name]])) + for (name in names(settings)) lsp_settings$set(name, settings[[name]]) + root <- index_normalize_path(withr::local_tempdir()) + helper <- file.path(root, "helper.R") + writeLines("helper <- function(value) value", helper) + path <- file.path(root, "main.R") + content <- '(function() source("helper.R"))()' + writeLines(content, path) + stale <- index_shallow_summary(path, content, root) + stale$sources <- list() + stale$source_candidates <- character() + stale$source_candidate_exists <- logical() + expect_true(index_source_candidates_current(stale)) + cache_file <- file.path(root, "index.rds") + saveRDS(list(version = 2L, root = root, summaries = list(stale)), cache_file) + + index <- WorkspaceIndex$new(root, cache_path = cache_file) + index$load_cache() + expect_identical(index$summaries$size(), 0L) + index$discover() + uri <- path_to_uri(path) + expect_true(uri %in% index$pending) + for (i in seq_len(5L)) { + index$process_batch() + if (index$complete()) break + } + expect_true(index$complete()) + expect_identical(index$source_edges$get(uri), path_to_uri(helper)) + expect_identical(index$summaries$get(uri)$sources, list(path_to_uri(helper))) + refreshed <- readRDS(cache_file) + expect_identical(refreshed$version, index_cache_version) + restored <- WorkspaceIndex$new(root, cache_path = cache_file) + restored$load_cache() + expect_identical(restored$source_edges$get(uri), path_to_uri(helper)) +}) From bb8eb8353a0f70efb5b54e1a518e17846158e2b4 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 02:54:01 +0800 Subject: [PATCH 09/16] Isolate diagnostic fixtures and align cancellation expectations --- tests/testthat/test-code-action.R | 4 +++- tests/testthat/test-handlers-langfeatures.R | 9 +++++---- tests/testthat/test-lintr.R | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/testthat/test-code-action.R b/tests/testthat/test-code-action.R index d6671226..98e75cfa 100644 --- a/tests/testthat/test-code-action.R +++ b/tests/testthat/test-code-action.R @@ -379,7 +379,9 @@ test_that("Code action provides quickfix for missing terminal newline", { }) test_that("Trailing blank line with spaces and no terminal newline fixes cleanly", { - withr::local_dir(withr::local_tempdir()) + directory <- withr::local_tempdir() + withr::local_dir(directory) + writeLines("linters: list(trailing_blank_lines_linter())", file.path(directory, ".lintr")) uri <- "untitled:1" content <- c("x <- 1", " ") document <- Document$new(uri, content = content) diff --git a/tests/testthat/test-handlers-langfeatures.R b/tests/testthat/test-handlers-langfeatures.R index 64ff6934..57e9f507 100644 --- a/tests/testthat/test-handlers-langfeatures.R +++ b/tests/testthat/test-handlers-langfeatures.R @@ -259,12 +259,13 @@ test_that("exit, cancellation, and trace notifications update server state", { self$pending_replies$set("file:///one.R", list(first, second)) cancel_request(self, list(id = 11L)) - expect_equal( - vapply(self$deliveries, function(x) as.character(x$id), character(1L)), - c("11", "11") - ) + expect_identical(lapply(self$deliveries, function(x) x$id), list(11L)) expect_equal(first$size(), 1L) expect_equal(first$peek()$id, 12L) + expect_equal(second$size(), 1L) + expect_identical(second$peek()$id, "11") + cancel_request(self, list(id = "11")) + expect_identical(lapply(self$deliveries, function(x) x$id), list(11L, "11")) expect_equal(second$size(), 0L) on_exit(self, NULL) diff --git a/tests/testthat/test-lintr.R b/tests/testthat/test-lintr.R index f76cc8bb..6a68ffe4 100644 --- a/tests/testthat/test-lintr.R +++ b/tests/testthat/test-lintr.R @@ -339,6 +339,7 @@ test_that("diagnose_file object_usage_linter acknowledges NAMESPACE imports and test_that("diagnose_file reports missing terminal newline for single-line and multi-line files", { dir <- withr::local_tempdir() withr::local_dir(dir) + writeLines("linters: list(trailing_blank_lines_linter())", file.path(dir, ".lintr")) temp_file <- withr::local_tempfile(tmpdir = dir, fileext = ".R") cat("x <- 1", file = temp_file) uri <- path_to_uri(temp_file) @@ -405,6 +406,7 @@ test_that("lint_without_terminal_newline respects linter configuration", { test_that("diagnose_file fallback preserves encoding, nolint directives, caching, and package context", { dir <- withr::local_tempdir() withr::local_dir(dir) + writeLines("linters: list(object_usage_linter(), trailing_blank_lines_linter())", file.path(dir, ".lintr")) temp_file <- withr::local_tempfile(tmpdir = dir, fileext = ".R") writeLines("x <- 1", temp_file) uri <- path_to_uri(temp_file) From 421336a28215b596eefdd989697d7230a657f828 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 08:42:20 +0800 Subject: [PATCH 10/16] Index namespace function identities during member preparation --- R/member-metadata.R | 45 +++++++++++++- inst/benchmarks/member-preparation.R | 75 ++++++++++++++++++++++++ tests/testthat/test-member-preparation.R | 57 ++++++++++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 inst/benchmarks/member-preparation.R create mode 100644 tests/testthat/test-member-preparation.R diff --git a/R/member-metadata.R b/R/member-metadata.R index b1af5990..fedfb80e 100644 --- a/R/member-metadata.R +++ b/R/member-metadata.R @@ -22,6 +22,44 @@ member_function_syntax <- function(fn) { as.call(list(as.name("function"), formals(fn), body(fn))) } +# Registry closures often already have a namespace name. Partition them by +# inert syntax before comparing identities, preserving the first matching +# binding even for equivalent copied closures or digest collisions. The memo +# retains each closure only for this extraction, so pointer reuse cannot turn +# an unrelated anonymous method into a cached match. +member_function_identity_index <- function(functions) { + buckets <- new.env(hash = TRUE, parent = emptyenv()) + memo <- new.env(hash = TRUE, parent = emptyenv()) + record <- function(fn) { + pointer <- .Call("member_function_key_c", fn, PACKAGE = "languageserver") + stored <- memo[[pointer]] + if (is.null(stored)) { + stored <- list(fn = fn, key = digest::digest(deparse1(member_function_syntax(fn)), algo = "xxhash64")) + memo[[pointer]] <- stored + } + list(pointer = pointer, stored = stored) + } + for (name in names(functions)) { + key <- record(functions[[name]])$stored$key + buckets[[key]] <- c(buckets[[key]], name) + } + function(fn) { + item <- record(fn) + stored <- item$stored + if (!isTRUE(stored$matched)) { + for (name in buckets[[stored$key]]) { + if (identical(functions[[name]], fn)) { + stored$name <- name + break + } + } + stored$matched <- TRUE + memo[[item$pointer]] <- stored + } + stored$name + } +} + member_list_storage <- function(value) { storage <- unclass(value) nms <- attr(value, "names", exact = TRUE) @@ -561,7 +599,7 @@ member_namespace_input <- function( } # Match bindings by identity, not a method-name prefix. Retain namespace # function IDs for documentation; anonymous registry closures get path IDs. - function_names <- names(functions) + function_match <- NULL remaining_bindings <- 50000L dollar_context <- member_snapshot_dollar_context(ns) for (name in names(values)) { @@ -606,8 +644,9 @@ member_namespace_input <- function( if (length(matches)) links[[name]][field] <- names(values)[[matches[[1L]]]] } if (!identical(record[[1L]], "value") || typeof(fn) != "closure") next - matches <- which(vapply(functions, identical, logical(1L), fn)) - key <- if (length(matches)) function_names[[matches[[1L]]]] else paste(name, field, sep = "$") + if (is.null(function_match)) function_match <- member_function_identity_index(functions) + key <- function_match(fn) + if (is.null(key)) key <- paste(name, field, sep = "$") definitions[key] <- list(member_function_syntax(fn)) # A static factory may also be addressed by its environment path. definitions[paste(name, field, sep = "$")] <- list(as.name(key)) diff --git a/inst/benchmarks/member-preparation.R b/inst/benchmarks/member-preparation.R new file mode 100644 index 00000000..c157a2ce --- /dev/null +++ b/inst/benchmarks/member-preparation.R @@ -0,0 +1,75 @@ +# R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/member-preparation.R results.csv 3 polars +# This workload reads installed namespace metadata only; it executes no query. +args <- commandArgs(trailingOnly = TRUE) +output <- if (length(args)) args[[1L]] else "member-preparation-performance.csv" +passes <- if (length(args) > 1L) as.integer(args[[2L]]) else 3L +package <- if (length(args) > 2L) args[[3L]] else "polars" +stopifnot(!is.na(passes), passes > 0L) +benchmark <- new.env(parent = asNamespace("languageserver")) +benchmark$output <- output +benchmark$passes <- passes +benchmark$package <- package +evalq({ + run <- function() { + directory <- tempfile("member-preparation-benchmark-") + dir.create(directory) + on.exit(unlink(directory, recursive = TRUE), add = TRUE) + previous <- options(languageserver.package_cache_dir = FALSE) + on.exit(options(previous), add = TRUE) + measurements <- new.env(parent = emptyenv()) + measurements$rows <- list() + measure <- function(stage, operation, validate = function(value) !is.null(value), n = passes) { + for (pass in seq_len(n)) { + gc() + start <- proc.time() + value <- operation() + elapsed <- proc.time() - start + stopifnot(validate(value)) + size_value <- if (is.environment(value) && identical(stage, "model_construction")) as.list(value) else value + measurements$rows[[length(measurements$rows) + 1L]] <- data.frame( + package = package, stage = stage, pass = pass, + elapsed_ms = 1000 * elapsed[[3L]], cpu_ms = 1000 * sum(elapsed[1:2]), + bytes = as.numeric(utils::object.size(size_value))) + } + value + } + namespace <- measure("namespace_load", function() asNamespace(package), is.environment, 1L) + identity <- measure("live_identity", function() package_metadata_live_identity(package), is.list) + input <- measure("namespace_extraction", function() member_namespace_input(namespace), is.list) + index <- measure("model_construction", function() member_package_index(input), is.environment) + prepare_returns <- function() { + context <- list2env(as.list(index), parent = emptyenv()) + context$cache <- new.env(parent = emptyenv()) + member_prepare_returns(context) + list(method_results = context$method_results, generic_returns = context$generic_returns) + } + returns <- prepare_returns() + measure("return_preparation", prepare_returns, function(value) identical(value, returns)) + index$identity <- identity + snapshot <- measure("snapshot_freeze", function() member_index_freeze(index), is.list) + if (identical(package, "polars")) { + value <- member_infer(quote(pl$DataFrame(list(value = 1L))$lazy()$filter(pl$col("value") > 0L)), + index, index$roots) + stopifnot(identical(value$type, "polars_lazy_frame")) + } + measure("uncached_preparation", function() member_prepare_package(package), is.list) + options(languageserver.package_cache_dir = directory) + path <- package_metadata_cache_path("members", identity) + measure("disk_write", function() { + package_metadata_cache_write(path, identity, snapshot) + stopifnot(file.exists(path)) + snapshot + }, is.list) + measure("disk_read", function() package_metadata_cache_read(path, identity), + function(value) identical(value, snapshot)) + unlink(path) + prepared <- measure("cache_creation", function() member_prepare_package(package), is.list, 1L) + measure("warm_preparation", function() member_prepare_package(package), + function(value) identical(value, prepared)) + results <- do.call(rbind, measurements$rows) + utils::write.csv(results, output, row.names = FALSE) + print(aggregate(cbind(elapsed_ms, cpu_ms) ~ stage, results, stats::median), row.names = FALSE) + cat("R:", as.character(getRversion()), "Package:", as.character(utils::packageVersion(package)), "\n") + } + run() +}, benchmark) diff --git a/tests/testthat/test-member-preparation.R b/tests/testthat/test-member-preparation.R new file mode 100644 index 00000000..ec49a038 --- /dev/null +++ b/tests/testthat/test-member-preparation.R @@ -0,0 +1,57 @@ +test_that("Namespaces without registry closures skip identity buckets", { + ns <- new.env(parent = baseenv()) + ns$first <- function(value = 1L) list(done = value) + ns$second <- function(flag = TRUE) if (flag) 1L else 0L + reference <- member_namespace_input(ns, "functionfixture", exports = c("first", "second")) + builds <- 0L + build <- member_function_identity_index + testthat::local_mocked_bindings(member_function_identity_index = function(functions) { + builds <<- builds + 1L + build(functions) + }, .package = "languageserver") + input <- member_namespace_input(ns, "functionfixture", exports = c("first", "second")) + expect_identical(builds, 0L) + expect_identical(input, reference) +}) + +test_that("Registry identity matching retains equivalent bindings and distinct closure scopes", { + scope <- new.env(parent = baseenv()) + syntax <- quote(function(value = 1L) list(done = value)) + first <- eval(syntax, scope) + copied <- compiler::cmpfun(eval(syntax, scope)) + other <- eval(syntax, new.env(parent = baseenv())) + expect_identical(first, copied) + expect_false(identical(first, other)) + ns <- new.env(parent = baseenv()) + ns$a_first <- first + ns$z_copied <- copied + ns$other <- other + ns$registry <- list(first = first, copied = copied, other = other, + anonymous = function(value = 1L) list(separate = value)) + input <- member_namespace_input(ns, "preparationfixture", exports = "registry") + expect_identical(input$registries$registry, c(first = "a_first", copied = "a_first", + other = "other", anonymous = "registry$anonymous")) + index <- member_package_index(input) + expect_identical(names(member_infer(quote(registry$copied()), index, index$roots)$fields), "done") + expect_identical(names(member_infer(quote(registry$anonymous()), index, index$roots)$fields), "separate") + testthat::local_mocked_bindings(member_function_identity_index = function(functions) { + function(fn) { + matches <- which(vapply(functions, identical, logical(1L), fn)) + if (length(matches)) names(functions)[[matches[[1L]]]] else NULL + } + }, .package = "languageserver") + reference <- member_namespace_input(ns, "preparationfixture", exports = "registry") + expect_identical(input, reference) + expect_identical(member_index_freeze(index), member_index_freeze(member_package_index(reference))) +}) + +test_that("Registry identity buckets still verify closures when syntax keys collide", { + first <- function(value) list(first = value) + second <- function(value) list(second = value) + testthat::local_mocked_bindings(member_function_syntax = function(fn) quote(function() NULL), + .package = "languageserver") + match <- member_function_identity_index(list(first = first, second = second)) + expect_identical(match(first), "first") + expect_identical(match(second), "second") + expect_null(match(function(value) list(unknown = value))) +}) From 13d91f95bf27341bf6f15bb0015d60c951b396d1 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 08:42:25 +0800 Subject: [PATCH 11/16] Avoid call machinery for member inference leaves --- R/member-inference.R | 83 ++++++++++--------- tests/testthat/test-member-inference-leaves.R | 37 +++++++++ 2 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 tests/testthat/test-member-inference-leaves.R diff --git a/R/member-inference.R b/R/member-inference.R index 8656251a..149a7325 100644 --- a/R/member-inference.R +++ b/R/member-inference.R @@ -560,6 +560,49 @@ member_infer <- function( if (isTRUE(ctx$r6_initialize)) ctx$r6_initialize <- FALSE member_infer(x, index, env, receiver, depth + 1L, trail, budget, ctx) } + # Symbols and literals need no call/flow machinery. Keep the shared node + # and time checks above, and retain ordinary nested-expression context + # when following a deferred binding. + if (is.symbol(expr)) { + key <- as.character(expr) + if (key %in% names(bindings)) { + value <- bindings[[key]] + if (!is.null(value$binding_expr)) { + if (!is.null(value$binding_cache)) { + return(member_local_value(value, index, budget, depth, trail, receiver, context)) + } + return(infer(value$binding_expr, value$binding_env)) + } + return(value) + } + if (key %in% names(index$document_bindings) || key %in% names(index$attached_roots)) { + value <- member_resolve_document(key, index, bindings, budget, depth, trail) + if (!is.null(value)) { + return(value) + } + } + if (identical(key, "self") && !is.null(receiver)) { + return(member_value(type = receiver)) + } + if (!is.null(member_lookup(index$definitions, key)) && (is.null(index$document_bindings) || + !is.null(context$key))) { + return(member_value(function_key = key)) + } + # Imported roots must be supplied by the document/package lexical + # context. Never resurrect an unattached or shadowed root by name. + if (key %in% c("NA", "NA_real_", "NA_integer_", "NA_character_", "TRUE", "FALSE", "NULL")) { + return(switch(key, + `TRUE` = member_literal(TRUE), + `FALSE` = member_literal(FALSE), + `NULL` = member_literal(NULL), + unknown + )) + } + return(unknown) + } + if (!is.call(expr)) { + return(member_literal(expr)) + } intrinsic <- function(name) { at <- bindings$.__member_position__ history <- member_lookup(index$document_bindings, name) @@ -967,46 +1010,6 @@ member_infer <- function( } result } - if (is.symbol(expr)) { - key <- as.character(expr) - if (key %in% names(bindings)) { - value <- bindings[[key]] - if (!is.null(value$binding_expr)) { - if (!is.null(value$binding_cache)) { - return(member_local_value(value, index, budget, depth, trail, receiver, context)) - } - return(infer(value$binding_expr, value$binding_env)) - } - return(value) - } - if (key %in% names(index$document_bindings) || key %in% names(index$attached_roots)) { - value <- member_resolve_document(key, index, bindings, budget, depth, trail) - if (!is.null(value)) { - return(value) - } - } - if (identical(key, "self") && !is.null(receiver)) { - return(member_value(type = receiver)) - } - if (!is.null(member_lookup(index$definitions, key)) && (is.null(index$document_bindings) || - !is.null(context$key))) { - return(member_value(function_key = key)) - } - # Imported roots must be supplied by the document/package lexical - # context. Never resurrect an unattached or shadowed root by name. - if (key %in% c("NA", "NA_real_", "NA_integer_", "NA_character_", "TRUE", "FALSE", "NULL")) { - return(switch(key, - `TRUE` = member_literal(TRUE), - `FALSE` = member_literal(FALSE), - `NULL` = member_literal(NULL), - unknown - )) - } - return(unknown) - } - if (!is.call(expr)) { - return(member_literal(expr)) - } head <- member_name(expr[[1L]]) if (is.null(head)) head <- "" if (member_head(expr, "(")) { diff --git a/tests/testthat/test-member-inference-leaves.R b/tests/testthat/test-member-inference-leaves.R new file mode 100644 index 00000000..fa106059 --- /dev/null +++ b/tests/testthat/test-member-inference-leaves.R @@ -0,0 +1,37 @@ +test_that("Leaf inference honors shared node and time limits", { + index <- member_generic_index("") + bindings <- list(value = member_literal(1L)) + for (expr in list(1L, quote(value))) { + budget <- new.env(parent = emptyenv()) + budget$remaining <- 1L + budget$exhausted <- budget$transient <- FALSE + expect_identical(member_infer(expr, index, bindings, budget = budget), member_literal(1L)) + expect_identical(budget$remaining, 0L) + expect_false(budget$exhausted) + expect_identical(member_infer(expr, index, bindings, budget = budget)$reason, "budget") + expect_identical(budget$remaining, -1L) + expect_true(budget$exhausted) + + budget <- new.env(parent = emptyenv()) + budget$remaining <- 100L + budget$exhausted <- budget$transient <- FALSE + budget$deadline <- -1 + expect_identical(member_infer(expr, index, bindings, budget = budget)$reason, "budget") + expect_true(budget$timed_out) + } +}) + +test_that("Leaf inference preserves lexical shadowing and deferred contexts", { + index <- member_generic_index("value <- function() list(other = TRUE)") + local <- member_literal(2L) + expect_identical(member_infer(quote(value), index, list(value = local)), local) + expect_identical(member_infer(quote(value), index)$function_key, "value") + body <- quote({ answer <- list(done = TRUE); answer }) + deferred <- member_value(binding_expr = body, binding_env = list()) + # Following a binding is an ordinary nested expression even when its + # caller is examining initializer effects. + result <- member_infer(quote(value), index, list(value = deferred), + context = list(r6_initialize = TRUE)) + expect_identical(result, member_infer(body, index)) + expect_identical(names(result$fields), "done") +}) From 975b2a63e60da020d87a5ce703fa8d1c2fc4a5ae Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 08:42:30 +0800 Subject: [PATCH 12/16] Separate queued task timing and redact session credentials --- R/handlers-general.R | 11 ++++++++++- R/task.R | 13 +++++++++++-- tests/testthat/test-handlers-general.R | 10 ++++++++++ tests/testthat/test-task-timings.R | 15 +++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 tests/testthat/test-task-timings.R diff --git a/R/handlers-general.R b/R/handlers-general.R index 6911a57d..fcf52583 100644 --- a/R/handlers-general.R +++ b/R/handlers-general.R @@ -1,3 +1,12 @@ +#' Remove common credential variables from the debug session header +#' @noRd +session_environment <- function(environment = Sys.getenv()) { + sensitive <- grepl("(^|_)(PAT|TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|ACCESS_KEY|PRIVATE_KEY|CLIENT_SECRET)($|_)", + names(environment), ignore.case = TRUE) + environment[sensitive] <- "" + as.list(environment) +} + #' `initialize` handler #' #' Handler to the `initialize` [Request]. @@ -16,7 +25,7 @@ on_initialize <- function(self, id, params) { args = commandArgs(), ver = unclass(R.version), locale = Sys.getlocale(), - env = as.list(Sys.getenv()), + env = session_environment(), libpaths = .libPaths(), namespaces = local({ nss <- loadedNamespaces() diff --git a/R/task.R b/R/task.R index de62dd47..dac9442f 100644 --- a/R/task.R +++ b/R/task.R @@ -31,6 +31,7 @@ Task <- R6::R6Class("Task", time = NULL, delay = NULL, started_at = NULL, + execution_started_at = NULL, initialize = function(target, args, callback = NULL, error = NULL, delay = 0, profiles = TRUE, track_descendants = FALSE) { private$target <- target @@ -49,6 +50,7 @@ Task <- R6::R6Class("Task", self$delay <- as.numeric(delay) }, start = function(session = NULL) { + self$execution_started_at <- proc.time()[[3L]] if (is.null(session)) { private$process <- callr::r_bg( private$target, @@ -63,6 +65,12 @@ Task <- R6::R6Class("Task", ) } }, + timings = function(now = proc.time()[[3L]]) { + total <- max(0, now - self$started_at) + queued <- if (is.null(self$execution_started_at)) total + else min(total, max(0, self$execution_started_at - self$started_at)) + c(total = total, queued = queued, running = total - queued) + }, check = function() { if (private$completed) return(TRUE) # callr can retain a buffered logical state after its native worker @@ -539,8 +547,9 @@ TaskManager <- R6::R6Class("TaskManager", check_result <- TRUE } if (isTRUE(check_result)) { - # FIXME: debug - logger$info(private$name, "task timing:", Sys.time() - task$time, " ", key) + timing <- task$timings() + logger$info(private$name, "task timing:", timing[["total"]], + " queued:", timing[["queued"]], " running:", timing[["running"]], " ", key) # The callback may have cancelled this entry or started a # different task with the same id. Only remove our own entry. if (identical(running_tasks$get(key, NULL), task)) { diff --git a/tests/testthat/test-handlers-general.R b/tests/testthat/test-handlers-general.R index 0d0e4956..288ac689 100644 --- a/tests/testthat/test-handlers-general.R +++ b/tests/testthat/test-handlers-general.R @@ -1,3 +1,13 @@ +test_that("session headers redact credential variables while retaining useful settings", { + environment <- c(GITHUB_PAT = "sample-pat", VSCODE_CLI_REQUIRE_TOKEN = "sample-token", + API_KEY = "sample-key", AWS_SECRET_ACCESS_KEY = "sample-secret", database_password = "sample-password", + PATH = "/example/bin", R_LIBS_USER = "/example/library", TOKENIZER_MODE = "ordinary") + header <- session_environment(environment) + expect_identical(unlist(header[seq_len(5L)], use.names = FALSE), rep("", 5L)) + expect_identical(unlist(header[-seq_len(5L)]), environment[-seq_len(5L)]) + expect_identical(environment[["GITHUB_PAT"]], "sample-pat") +}) + test_that("shutdown responds with a null result", { self <- new.env(parent = baseenv()) self$exit_flag <- FALSE diff --git a/tests/testthat/test-task-timings.R b/tests/testthat/test-task-timings.R new file mode 100644 index 00000000..58d5a69d --- /dev/null +++ b/tests/testthat/test-task-timings.R @@ -0,0 +1,15 @@ +test_that("task timings separate queue delay from dispatched work", { + task <- create_task(function() NULL, list()) + origin <- task$started_at + expect_equal(task$timings(now = origin + 3), c(total = 3, queued = 3, running = 0)) + # Starting a task records dispatch separately from creation, even if a + # session completes synchronously. Use an inert session rather than sleep. + session <- list(call = function(...) NULL) + task$start(session) + dispatched <- task$execution_started_at + timing <- task$timings(now = dispatched + 5) + expect_equal(timing[["queued"]], dispatched - origin) + expect_equal(timing[["running"]], 5) + expect_equal(timing[["total"]], sum(timing[c("queued", "running")])) + expect_equal(task$timings(now = origin - 1), c(total = 0, queued = 0, running = 0)) +}) From 7871589a9961284438dddff3c66f924a64624892 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 10:10:57 +0800 Subject: [PATCH 13/16] Coalesce member refreshes without cancelling active preparation --- R/document.R | 66 ++++++- R/task.R | 5 + tests/testthat/test-member-task-refresh.R | 222 ++++++++++++++++++++++ 3 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 tests/testthat/test-member-task-refresh.R diff --git a/R/document.R b/R/document.R index 12cf2551..709a7be9 100644 --- a/R/document.R +++ b/R/document.R @@ -1099,8 +1099,19 @@ resolve_callback <- function(self, uri, version, packages) { workspace$update_loaded_packages() } +member_task_request <- function(workspace, document, packages, namespace_packages) { + list(workspace = workspace, document = document, + packages = normalize_package_request(packages), + namespace_packages = normalize_package_request(namespace_packages), + requested = document$requested_packages, lib_paths = .libPaths(), + cache_dir = package_metadata_cache_root()) +} + resolve_member_task <- function(self, uri, document, packages, namespace_packages) { workspace <- self$get_workspace(uri) + state <- new.env(parent = emptyenv()) + state$request <- member_task_request(workspace, document, packages, namespace_packages) + state$refresh_pending <- FALSE metadata <- workspace$member_metadata known <- list() if (!is.null(metadata)) { @@ -1114,38 +1125,75 @@ resolve_member_task <- function(self, uri, document, packages, namespace_package requested <- document$requested_packages version <- document$version current <- function() { - identical(self$get_workspace(uri), workspace) && + manager <- self$member_task_manager + active <- if (is.function(manager$get_task)) manager$get_task(uri) else NULL + isTRUE(lsp_settings$get("member_completion")) && + (!is.function(manager$get_task) || identical(active$context, state)) && + identical(.libPaths(), state$request$lib_paths) && + identical(self$get_workspace(uri), workspace) && identical(workspace$documents$get(uri, NULL), document) && identical(document$requested_packages, requested) } - create_task( + refresh <- function() { + if (!isTRUE(state$refresh_pending) || !current()) return(invisible(NULL)) + state$refresh_pending <- FALSE + self$member_task_manager$add_task(uri, + resolve_member_task(self, uri, document, packages, namespace_packages)) + invisible(NULL) + } + task <- create_task( target = package_call(member_resolve_packages), - args = list(pkgs = packages, lib_paths = .libPaths(), namespace_packages = namespace_packages, - prepare = TRUE, prepare_s3 = FALSE, known_members = known, cache_dir = package_metadata_cache_root()), + args = list(pkgs = packages, lib_paths = state$request$lib_paths, namespace_packages = namespace_packages, + prepare = TRUE, prepare_s3 = FALSE, known_members = known, cache_dir = state$request$cache_dir), callback = function(result) { + on.exit({ + refresh() + s3_queue_ready(self, uri) + }, add = TRUE) if (current()) resolve_callback(self, uri, version, result) }, error = function(e) { - on.exit(s3_queue_ready(self, uri), add = TRUE) + on.exit({ + refresh() + s3_queue_ready(self, uri) + }, add = TRUE) logger$info("member metadata task:", e) }, profiles = FALSE ) + task$context <- state + task } resolve_task <- function(self, uri, document, packages, delay = 0, namespace_packages = character()) { split_members <- !is.null(self$member_task_manager) - if (split_members) self$member_task_manager$cancel(uri) + prepare_members <- isTRUE(lsp_settings$get("member_completion")) + if (split_members && (!prepare_members || (!length(packages) && !length(namespace_packages)))) { + self$member_task_manager$cancel(uri) + } # Startup packages already belong to the workspace. A document with no # package requests needs no subprocess to rediscover that same search path. if (!length(packages) && !length(namespace_packages)) return(NULL) version <- document$version - prepare_members <- isTRUE(lsp_settings$get("member_completion")) # Neither worker depends on the other's result. In particular, an S3 # timeout must not prevent member models from being prepared and applied. if (split_members && prepare_members) { - self$member_task_manager$add_task(uri, - resolve_member_task(self, uri, document, packages, namespace_packages)) + manager <- self$member_task_manager + previous <- if (is.function(manager$get_task)) manager$get_task(uri) else NULL + request <- member_task_request(self$get_workspace(uri), document, packages, namespace_packages) + if (is.environment(previous$context) && identical(previous$context$request, request)) { + # A queued worker already sees the latest package installation. + # Running work finishes once, then one fresh worker validates all + # saves received during preparation without repeating that work. + if (is.null(previous$execution_started_at)) { + if (is.function(manager$promote)) manager$promote(uri) + } else { + previous$context$refresh_pending <- TRUE + } + } else { + manager$add_task(uri, + resolve_member_task(self, uri, document, packages, namespace_packages)) + } } create_task( target = package_call(member_resolve_packages), diff --git a/R/task.R b/R/task.R index dac9442f..2130cc8d 100644 --- a/R/task.R +++ b/R/task.R @@ -32,6 +32,7 @@ Task <- R6::R6Class("Task", delay = NULL, started_at = NULL, execution_started_at = NULL, + context = NULL, initialize = function(target, args, callback = NULL, error = NULL, delay = 0, profiles = TRUE, track_descendants = FALSE) { private$target <- target @@ -401,6 +402,10 @@ TaskManager <- R6::R6Class("TaskManager", private$ensure_min_sessions() } }, + get_task = function(id) { + task <- private$pending_tasks$get(id, NULL) + if (is.null(task)) private$running_tasks$get(id, NULL) else task + }, add_task = function(id, task) { if (private$stopping) { return(NULL) diff --git a/tests/testthat/test-member-task-refresh.R b/tests/testthat/test-member-task-refresh.R new file mode 100644 index 00000000..1206eb2d --- /dev/null +++ b/tests/testthat/test-member-task-refresh.R @@ -0,0 +1,222 @@ +member_refresh_workspace <- function(document) { + workspace <- new.env(parent = emptyenv()) + workspace$documents <- collections::dict() + workspace$documents$set(document$uri, document) + workspace$member_metadata <- collections::dict() + workspace$applied <- list() + workspace$load_packages <- function(packages) { + workspace$applied[[length(workspace$applied) + 1L]] <- packages + } + workspace$update_loaded_packages <- function() NULL + workspace +} + +member_refresh_fixture <- function(.local_envir = parent.frame()) { + withr::local_envvar(R_COVR = "false", .local_envir = .local_envir) + previous <- lsp_settings$get("member_completion") + withr::defer(lsp_settings$set("member_completion", previous), envir = .local_envir) + lsp_settings$set("member_completion", TRUE) + document <- Document$new("file:///member-refresh.R", "r", 1L, "value <- 1L") + document$requested_packages <- list(packages = c("firstfixture", "secondfixture"), + namespace_packages = c("memberfixture", "otherfixture")) + workspace <- member_refresh_workspace(document) + server <- new.env(parent = emptyenv()) + server$workspace <- workspace + server$get_workspace <- function(...) server$workspace + server$pending_replies <- collections::dict() + server$queue_pending_replies <- function(...) NULL + server$member_task_manager <- TaskManager$new("refresh", use_session = TRUE, + min_idle_sessions = 0L, max_running_tasks = 1L, cpu_load = 1) + withr::defer(server$member_task_manager$stop(), envir = .local_envir) + session_state <- new.env(parent = emptyenv()) + session_state$status <- "idle" + session_state$ready <- FALSE + session_state$killed <- 0L + session <- list( + call = function(...) session_state$status <- "busy", + get_state = function() session_state$status, + read = function() { + if (!session_state$ready || session_state$status != "busy") return(NULL) + session_state$status <- "idle" + session_state$ready <- FALSE + if (!is.null(session_state$error)) { + list(code = 500L, error = session_state$error) + } else { + list(code = 200L, error = NULL, result = session_state$result) + } + }, + kill = function(...) { + session_state$killed <- session_state$killed + 1L + session_state$status <- "finished" + }, + close = function(...) NULL + ) + server$member_task_manager$.__enclos_env__$private$sessions <- list(session) + refresh <- function() { + resolve_task(server, document$uri, document, document$requested_packages$packages, + namespace_packages = document$requested_packages$namespace_packages) + } + list(server = server, workspace = workspace, document = document, + manager = server$member_task_manager, session = session_state, refresh = refresh) +} + +member_refresh_result <- function(fixture) { + index <- member_generic_index("") + index$package <- "memberfixture" + index$identity <- list(package = "memberfixture", revision = 2L) + list(packages = fixture$document$requested_packages$packages, + members = list(memberfixture = member_index_freeze(index)), s3 = NULL, + requested = fixture$document$requested_packages) +} + +test_that("Repeated queued member refreshes retain and promote one worker", { + fixture <- member_refresh_fixture() + first_resolve <- fixture$refresh() + task <- fixture$manager$get_task(fixture$document$uri) + task$delay <- 60 + for (i in 1:3) { + resolve <- fixture$refresh() + expect_false(identical(resolve, first_resolve)) + expect_identical(fixture$manager$get_task(fixture$document$uri), task) + } + expect_null(task$execution_started_at) + expect_identical(task$delay, 0) + expect_false(task$context$refresh_pending) + expect_identical(fixture$manager$.__enclos_env__$private$pending_tasks$size(), 1L) +}) + +test_that("Running member refreshes finish once then capture fresh metadata in one followup", { + fixture <- member_refresh_fixture() + fixture$refresh() + task <- fixture$manager$get_task(fixture$document$uri) + fixture$manager$run_tasks() + expect_false(is.null(task$execution_started_at)) + fixture$document$set_content(2L, "value <- 2L") + for (i in 1:3) fixture$refresh() + expect_identical(fixture$manager$get_task(fixture$document$uri), task) + expect_true(task$context$refresh_pending) + expect_identical(fixture$session$killed, 0L) + fixture$session$result <- member_refresh_result(fixture) + fixture$session$ready <- TRUE + fixture$manager$check_tasks() + followup <- fixture$manager$get_task(fixture$document$uri) + expect_false(identical(followup, task)) + expect_false(task$context$refresh_pending) + expect_false(followup$context$refresh_pending) + expect_null(followup$execution_started_at) + expect_length(fixture$workspace$applied, 1L) + expect_identical(followup$.__enclos_env__$private$args$known_members$memberfixture, + fixture$session$result$members$memberfixture$identity) + expect_identical(fixture$manager$.__enclos_env__$private$pending_tasks$size(), 1L) + fixture$manager$run_tasks() + fixture$session$ready <- TRUE + fixture$manager$check_tasks() + expect_null(fixture$manager$get_task(fixture$document$uri)) + expect_length(fixture$workspace$applied, 2L) + expect_identical(fixture$session$killed, 0L) +}) + +test_that("Failed member preparation also coalesces running refreshes into one retry", { + fixture <- member_refresh_fixture() + fixture$refresh() + task <- fixture$manager$get_task(fixture$document$uri) + fixture$manager$run_tasks() + for (i in 1:3) fixture$refresh() + fixture$session$error <- simpleError("fixture failure") + fixture$session$ready <- TRUE + fixture$manager$check_tasks() + followup <- fixture$manager$get_task(fixture$document$uri) + expect_false(identical(followup, task)) + expect_false(followup$context$refresh_pending) + expect_length(fixture$workspace$applied, 0L) + expect_identical(fixture$manager$.__enclos_env__$private$pending_tasks$size(), 1L) + fixture$session$error <- NULL + fixture$session$result <- member_refresh_result(fixture) + fixture$manager$run_tasks() + fixture$session$ready <- TRUE + fixture$manager$check_tasks() + expect_null(fixture$manager$get_task(fixture$document$uri)) + expect_length(fixture$workspace$applied, 1L) +}) + +test_that("Member application failures retain a requested refresh and newly applied identities", { + fixture <- member_refresh_fixture() + fixture$refresh() + task <- fixture$manager$get_task(fixture$document$uri) + fixture$manager$run_tasks() + fixture$refresh() + fixture$workspace$load_packages <- function(...) stop("fixture application failure") + fixture$session$result <- member_refresh_result(fixture) + fixture$session$ready <- TRUE + fixture$manager$check_tasks() + followup <- fixture$manager$get_task(fixture$document$uri) + expect_false(identical(followup, task)) + expect_identical(followup$.__enclos_env__$private$args$known_members$memberfixture, + fixture$session$result$members$memberfixture$identity) + expect_false(followup$context$refresh_pending) + expect_identical(fixture$session$killed, 0L) +}) + +test_that("Disabled members wake queued replies without scheduling another refresh", { + fixture <- member_refresh_fixture() + ready <- 0L + fixture$server$queue_pending_replies <- function(...) ready <<- ready + 1L + fixture$refresh() + fixture$manager$run_tasks() + fixture$refresh() + lsp_settings$set("member_completion", FALSE) + fixture$session$result <- member_refresh_result(fixture) + fixture$session$ready <- TRUE + fixture$manager$check_tasks() + expect_null(fixture$manager$get_task(fixture$document$uri)) + expect_length(fixture$workspace$applied, 0L) + expect_identical(ready, 1L) +}) + +test_that("Changed member context replaces work and stale completions cannot queue refreshes", { + check <- function(change) { + fixture <- member_refresh_fixture() + fixture$refresh() + task <- fixture$manager$get_task(fixture$document$uri) + task$context$refresh_pending <- TRUE + result <- member_refresh_result(fixture) + if (change == "packages") { + fixture$document$requested_packages$packages <- rev(fixture$document$requested_packages$packages) + } else if (change == "namespaces") { + fixture$document$requested_packages$namespace_packages <- rev(fixture$document$requested_packages$namespace_packages) + } else if (change == "workspace") { + fixture$server$workspace <- member_refresh_workspace(fixture$document) + } else if (change == "document") { + replacement <- Document$new(fixture$document$uri, "r", 1L, "value <- 3L") + replacement$requested_packages <- fixture$document$requested_packages + fixture$workspace$documents$set(replacement$uri, replacement) + } else if (change == "library") { + withr::local_libpaths(withr::local_tempdir(), action = "prefix") + } else if (change == "disabled") { + lsp_settings$set("member_completion", FALSE) + } else if (change == "empty") { + fixture$document$requested_packages <- list(packages = character(), namespace_packages = character()) + } else if (change == "cancelled" || change == "replaced") { + fixture$manager$cancel(fixture$document$uri) + } + if (change == "document") { + resolve_task(fixture$server, replacement$uri, replacement, + replacement$requested_packages$packages, + namespace_packages = replacement$requested_packages$namespace_packages) + } else if (change != "cancelled") { + fixture$refresh() + } + replacement <- fixture$manager$get_task(fixture$document$uri) + expect_false(identical(replacement, task), info = change) + if (change %in% c("disabled", "empty", "cancelled")) expect_null(replacement) + else expect_s3_class(replacement, "Task") + task$.__enclos_env__$private$callback(result) + task$.__enclos_env__$private$error(simpleError("stale fixture failure")) + expect_identical(fixture$manager$get_task(fixture$document$uri), replacement, info = change) + expect_length(fixture$workspace$applied, 0L) + expect_length(fixture$server$workspace$applied, 0L) + } + for (change in c("packages", "namespaces", "workspace", "document", "library", "disabled", "empty", "cancelled", "replaced")) { + check(change) + } +}) From 10ae7c477e8f97615703c1b1c06813dae8e53413 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 10:11:04 +0800 Subject: [PATCH 14/16] Evict unprotected cache entries without scanning every key --- R/workspace.R | 10 +++++ tests/testthat/test-cache.R | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/R/workspace.R b/R/workspace.R index b18a8d08..d5e2e106 100644 --- a/R/workspace.R +++ b/R/workspace.R @@ -54,6 +54,16 @@ ByteLruCache <- R6::R6Class( trim = function(protect = character()) { while (private$entries$size() > private$max_entries || private$current_bytes > private$max_bytes) { + # The common unprotected case can remove the oldest entry + # without materializing every key on each cache eviction. + # Older collections versions keep the existing keys path. + if (!length(protect) && is.function(private$entries$popitem)) { + key <- private$entries$popitem(last = FALSE)$key + size <- private$sizes$get(key, 0) + private$sizes$remove(key) + private$current_bytes <- max(private$current_bytes - size, 0) + next + } keys <- private$entries$keys() if (!length(keys)) break candidates <- setdiff(keys, protect) diff --git a/tests/testthat/test-cache.R b/tests/testthat/test-cache.R index 1cb94c64..6f74a8b7 100644 --- a/tests/testthat/test-cache.R +++ b/tests/testthat/test-cache.R @@ -55,6 +55,94 @@ test_that("ByteLruCache prefers protected entries while retaining its bounds", { expect_lte(cache$bytes(), 2 * as.numeric(object.size(raw(100)))) }) +test_that("Unprotected cache eviction avoids materializing the key collection", { + cache <- ByteLruCache$new(max_bytes = 10000, max_entries = 2L) + private <- cache$.__enclos_env__$private + entries <- private$entries + skip_if_not(is.function(entries$popitem), "collections has no direct oldest-item eviction") + private$entries <- list(set = entries$set, get = entries$get, pop = entries$pop, + remove = entries$remove, has = entries$has, size = entries$size, + popitem = entries$popitem, + keys = function() stop("Full key collection requested"), + as_list = function() stop("Full entry collection requested")) + cache$set("first", 1L) + cache$set("second", 2L) + cache$get("first") + expect_no_error(cache$set("third", 3L)) + expect_identical(entries$keys(), list("first", "third")) + expect_identical(cache$bytes(), 2 * as.numeric(object.size(1L))) + # Oversized replacement drops only the replaced entry, without eviction + # or stale size accounting for the value it used to hold. + cache$set("first", raw(10000L)) + expect_identical(entries$keys(), list("third")) + expect_identical(cache$bytes(), as.numeric(object.size(1L))) +}) + +test_that("Cache eviction and legacy API fallback match ordered reference sequences", { + max_bytes <- 4 * as.numeric(object.size(raw(50L))) + max_entries <- 3L + fast <- ByteLruCache$new(max_bytes, max_entries) + fallback <- ByteLruCache$new(max_bytes, max_entries) + entries <- fallback$.__enclos_env__$private$entries + # collections >= 0.3.0 installations may expose the older API without + # popitem. Retain its keys-based eviction and all public cache operations. + fallback$.__enclos_env__$private$entries <- list(set = entries$set, get = entries$get, + pop = entries$pop, remove = entries$remove, has = entries$has, + size = entries$size, keys = entries$keys, clear = entries$clear, as_list = entries$as_list) + state <- new.env(parent = emptyenv()) + state$values <- stats::setNames(list(), character()) + state$sizes <- numeric() + reference_remove <- function(key) { + state$values[key] <- NULL + state$sizes <- state$sizes[setdiff(names(state$sizes), key)] + } + reference_set <- function(key, value, protect, additional_bytes) { + reference_remove(key) + size <- as.numeric(object.size(value)) + additional_bytes + if (size > max_bytes) return() + state$values[key] <- list(value) + state$sizes[key] <- size + # This is the former eviction rule, using an independent ordered list. + while (length(state$values) > max_entries || sum(state$sizes) > max_bytes) { + keys <- names(state$values) + candidates <- setdiff(keys, protect) + reference_remove(if (length(candidates)) candidates[[1L]] else keys[[1L]]) + } + } + withr::local_seed(812L) + operations <- c("set", "set", "get", "set", "remove", "set", "oversize", "clear", + sample(c("set", "set", "get", "remove", "oversize", "clear"), 80L, replace = TRUE)) + for (operation in operations) { + key <- sample(letters[1:6], 1L) + if (operation %in% c("set", "oversize")) { + value <- raw(if (operation == "oversize") 1000L else sample(c(1L, 50L, 100L, 150L), 1L)) + protect <- sample(letters[1:6], sample(0:3, 1L)) + additional_bytes <- sample(c(0, 25, 100), 1L) + reference_set(key, value, protect, additional_bytes) + for (cache in list(fast, fallback)) cache$set(key, value, protect, additional_bytes) + } else if (operation == "get") { + expected <- state$values[[key]] + if (key %in% names(state$values)) { + state$values[key] <- NULL + state$values[key] <- list(expected) + } + for (cache in list(fast, fallback)) expect_identical(cache$get(key), expected) + } else if (operation == "remove") { + reference_remove(key) + for (cache in list(fast, fallback)) cache$remove(key) + } else { + state$values <- stats::setNames(list(), character()) + state$sizes <- numeric() + for (cache in list(fast, fallback)) cache$clear() + } + for (cache in list(fast, fallback)) { + expect_identical(cache$.__enclos_env__$private$entries$as_list(), state$values) + expect_identical(cache$bytes(), as.numeric(sum(state$sizes))) + expect_identical(cache$size(), length(state$values)) + } + } +}) + test_that("Member metadata restores decoded indexes from bounded inert snapshots", { index <- member_generic_index("run <- function(value = 1) list(done = value)") index$exports <- "run" From 1ccc0ce8d7b2947033595345b2e368a58ba252b6 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 10:18:59 +0800 Subject: [PATCH 15/16] Revert "Avoid call machinery for member inference leaves" This reverts commit 13d91f95bf27341bf6f15bb0015d60c951b396d1. --- R/member-inference.R | 83 +++++++++---------- tests/testthat/test-member-inference-leaves.R | 37 --------- 2 files changed, 40 insertions(+), 80 deletions(-) delete mode 100644 tests/testthat/test-member-inference-leaves.R diff --git a/R/member-inference.R b/R/member-inference.R index 149a7325..8656251a 100644 --- a/R/member-inference.R +++ b/R/member-inference.R @@ -560,49 +560,6 @@ member_infer <- function( if (isTRUE(ctx$r6_initialize)) ctx$r6_initialize <- FALSE member_infer(x, index, env, receiver, depth + 1L, trail, budget, ctx) } - # Symbols and literals need no call/flow machinery. Keep the shared node - # and time checks above, and retain ordinary nested-expression context - # when following a deferred binding. - if (is.symbol(expr)) { - key <- as.character(expr) - if (key %in% names(bindings)) { - value <- bindings[[key]] - if (!is.null(value$binding_expr)) { - if (!is.null(value$binding_cache)) { - return(member_local_value(value, index, budget, depth, trail, receiver, context)) - } - return(infer(value$binding_expr, value$binding_env)) - } - return(value) - } - if (key %in% names(index$document_bindings) || key %in% names(index$attached_roots)) { - value <- member_resolve_document(key, index, bindings, budget, depth, trail) - if (!is.null(value)) { - return(value) - } - } - if (identical(key, "self") && !is.null(receiver)) { - return(member_value(type = receiver)) - } - if (!is.null(member_lookup(index$definitions, key)) && (is.null(index$document_bindings) || - !is.null(context$key))) { - return(member_value(function_key = key)) - } - # Imported roots must be supplied by the document/package lexical - # context. Never resurrect an unattached or shadowed root by name. - if (key %in% c("NA", "NA_real_", "NA_integer_", "NA_character_", "TRUE", "FALSE", "NULL")) { - return(switch(key, - `TRUE` = member_literal(TRUE), - `FALSE` = member_literal(FALSE), - `NULL` = member_literal(NULL), - unknown - )) - } - return(unknown) - } - if (!is.call(expr)) { - return(member_literal(expr)) - } intrinsic <- function(name) { at <- bindings$.__member_position__ history <- member_lookup(index$document_bindings, name) @@ -1010,6 +967,46 @@ member_infer <- function( } result } + if (is.symbol(expr)) { + key <- as.character(expr) + if (key %in% names(bindings)) { + value <- bindings[[key]] + if (!is.null(value$binding_expr)) { + if (!is.null(value$binding_cache)) { + return(member_local_value(value, index, budget, depth, trail, receiver, context)) + } + return(infer(value$binding_expr, value$binding_env)) + } + return(value) + } + if (key %in% names(index$document_bindings) || key %in% names(index$attached_roots)) { + value <- member_resolve_document(key, index, bindings, budget, depth, trail) + if (!is.null(value)) { + return(value) + } + } + if (identical(key, "self") && !is.null(receiver)) { + return(member_value(type = receiver)) + } + if (!is.null(member_lookup(index$definitions, key)) && (is.null(index$document_bindings) || + !is.null(context$key))) { + return(member_value(function_key = key)) + } + # Imported roots must be supplied by the document/package lexical + # context. Never resurrect an unattached or shadowed root by name. + if (key %in% c("NA", "NA_real_", "NA_integer_", "NA_character_", "TRUE", "FALSE", "NULL")) { + return(switch(key, + `TRUE` = member_literal(TRUE), + `FALSE` = member_literal(FALSE), + `NULL` = member_literal(NULL), + unknown + )) + } + return(unknown) + } + if (!is.call(expr)) { + return(member_literal(expr)) + } head <- member_name(expr[[1L]]) if (is.null(head)) head <- "" if (member_head(expr, "(")) { diff --git a/tests/testthat/test-member-inference-leaves.R b/tests/testthat/test-member-inference-leaves.R deleted file mode 100644 index fa106059..00000000 --- a/tests/testthat/test-member-inference-leaves.R +++ /dev/null @@ -1,37 +0,0 @@ -test_that("Leaf inference honors shared node and time limits", { - index <- member_generic_index("") - bindings <- list(value = member_literal(1L)) - for (expr in list(1L, quote(value))) { - budget <- new.env(parent = emptyenv()) - budget$remaining <- 1L - budget$exhausted <- budget$transient <- FALSE - expect_identical(member_infer(expr, index, bindings, budget = budget), member_literal(1L)) - expect_identical(budget$remaining, 0L) - expect_false(budget$exhausted) - expect_identical(member_infer(expr, index, bindings, budget = budget)$reason, "budget") - expect_identical(budget$remaining, -1L) - expect_true(budget$exhausted) - - budget <- new.env(parent = emptyenv()) - budget$remaining <- 100L - budget$exhausted <- budget$transient <- FALSE - budget$deadline <- -1 - expect_identical(member_infer(expr, index, bindings, budget = budget)$reason, "budget") - expect_true(budget$timed_out) - } -}) - -test_that("Leaf inference preserves lexical shadowing and deferred contexts", { - index <- member_generic_index("value <- function() list(other = TRUE)") - local <- member_literal(2L) - expect_identical(member_infer(quote(value), index, list(value = local)), local) - expect_identical(member_infer(quote(value), index)$function_key, "value") - body <- quote({ answer <- list(done = TRUE); answer }) - deferred <- member_value(binding_expr = body, binding_env = list()) - # Following a binding is an ordinary nested expression even when its - # caller is examining initializer effects. - result <- member_infer(quote(value), index, list(value = deferred), - context = list(r6_initialize = TRUE)) - expect_identical(result, member_infer(body, index)) - expect_identical(names(result$fields), "done") -}) From 3c5cd7475b2a76985936a901e88d1b350d0bb71d Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 31 Oct 2026 10:21:33 +0800 Subject: [PATCH 16/16] Document isolated member-worker benchmarks and timing phases --- NEWS.md | 9 +++++ inst/benchmarks/README.md | 48 ++++++++++++++++++++++++++ inst/benchmarks/member-tasks.R | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 inst/benchmarks/member-tasks.R diff --git a/NEWS.md b/NEWS.md index 171691bf..7e627a03 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,14 @@ # languageserver 0.3.20 +- Prepare member metadata with indexed namespace function matching and direct + eviction of unprotected cache entries, preserving exact identities, LRU order + and memory limits. +- Coalesce repeated member refreshes: queued work is promoted and running work + finishes before one fresh validation, instead of restarting on every save. + Changed document, workspace and package contexts still replace obsolete work. +- Split debug task timings into total, queued and running time using the elapsed + clock. Redact common credential variables from the debug session header. + - Keep numeric and string request IDs distinct during cancellation, background formatting and response dispatch. Normalize equivalent numeric IDs across their integer and double representations. diff --git a/inst/benchmarks/README.md b/inst/benchmarks/README.md index 02c62e01..71a4f52b 100644 --- a/inst/benchmarks/README.md +++ b/inst/benchmarks/README.md @@ -46,6 +46,54 @@ scripts report R allocations when memory profiling is supported. Local timings vary with hardware and load; regression tests assert behavior without elapsed thresholds. +## Member metadata workers + +Run these against separately installed revisions, without concurrent tests or +benchmarks: + +```sh +R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/member-preparation.R stages.csv 3 polars +R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/member-tasks.R workers.csv 3 polars +``` + +The stage workload separates namespace loading, live identity validation, +registry extraction, model/return preparation, freezing and disk operations. +The worker workload uses a fresh process with startup profiles disabled for +every request, as production member tasks do. Cold rows have empty caches; +disk rows restore existing models; known rows validate identities already held +by the workspace and return no duplicate models. Worker timings include server +loading, namespace preparation and metadata transfer, but exclude scheduling, +document parsing and editor rendering. All validation uses neutral fixtures and +static inference; no data query or user document expression is executed. + +Three serial local passes on R 4.6.1/macOS arm64 with Polars 2.0.0, comparing +`bb8eb83` with the member-preparation changes, produced these medians: + +| Fresh worker workload | Before (ms) | After (ms) | +| --- | ---: | ---: | +| Cold member preparation | 10,529 | 7,156 | +| Existing disk model | 3,771 | 1,763 | +| Already-known model validation | 3,564 | 1,512 | + +An isolated paired registry-extraction comparison also fell from 2,478 ms to +692 ms with identical complete input. Fresh live-identity validation fell from +approximately 2,440 ms to 437 ms with identical contexts and identity records. +Much of the validation cost came from materializing every cache key on each +eviction. The direct oldest-item path avoids that scan for unprotected entries; +protected entries and older collection APIs retain their previous behavior. +Memory budgets and live namespace/file checks are unchanged. + +Repeated saves retain the same queued or running member task when its complete +request context is unchanged. Saves received during running work trigger one +fresh validation after that work completes. New package/library/workspace +contexts replace it immediately. These lifecycle checks use inert sessions and +assert task identity, callback safety, and fresh metadata capture without timing +thresholds. + +Debug task lines now include `queued:` and `running:` alongside total time. +The running interval begins at dispatch and includes process startup, result +transfer and callbacks; it is not a measurement of worker CPU time alone. + ## Member metadata cache Run `R_LIBS=/path/to/library Rscript inst/benchmarks/member-metadata.R results.csv 10` diff --git a/inst/benchmarks/member-tasks.R b/inst/benchmarks/member-tasks.R new file mode 100644 index 00000000..88a01c54 --- /dev/null +++ b/inst/benchmarks/member-tasks.R @@ -0,0 +1,61 @@ +# R_LIBS=/path/to/library Rscript --vanilla inst/benchmarks/member-tasks.R results.csv 3 polars +# Fresh workers match member tasks: profiles are disabled and namespaces are +# not retained between requests. Measure startup and metadata transfer too. +args <- commandArgs(TRUE) +output <- if (length(args)) args[[1L]] else "member-task-performance.csv" +passes <- if (length(args) > 1L) as.integer(args[[2L]]) else 3L +package <- if (length(args) > 2L) args[[3L]] else "polars" +stopifnot(!is.na(passes), passes > 0L) +libraries <- .libPaths() +directory <- tempfile("member-task-benchmark-") +dir.create(directory) +worker <- function(libraries, package, directory, known) { + .libPaths(libraries) + namespace <- asNamespace("languageserver") + namespace$member_resolve_packages(package, libraries, prepare_s3 = FALSE, + known_members = known, cache_dir = directory) +} +invoke <- function(directory, known = list()) { + callr::r(worker, + args = list(libraries = libraries, package = package, directory = directory, known = known), + system_profile = FALSE, user_profile = FALSE) +} +run <- function() { + on.exit(unlink(directory, recursive = TRUE), add = TRUE) + rows <- list() + known <- list() + for (mode in c("cold", "disk", "known")) { + if (mode == "disk") { + # Populate outside the warm timers in a separate fresh worker. + seed <- invoke(directory) + stopifnot(is.null(seed$members[[package]]$error), !is.null(seed$members[[package]]$identity)) + known[package] <- list(seed$members[[package]]$identity) + } + for (pass in seq_len(passes)) { + cache <- if (mode == "cold") file.path(directory, paste0("cold-", pass)) else directory + gc() + started <- proc.time()[[3L]] + result <- invoke(cache, if (mode == "known") known else list()) + elapsed <- proc.time()[[3L]] - started + if (mode == "known") { + stopifnot(length(result$members) == 0L) + } else { + snapshot <- result$members[[package]] + stopifnot(is.null(snapshot$error), identical(snapshot$schema, 1L)) + if (package == "polars") { + namespace <- asNamespace("languageserver") + index <- namespace$member_index_thaw(snapshot) + value <- namespace$member_infer(quote(pl$DataFrame(list(reading = 1L))$lazy()), index, index$roots) + stopifnot(identical(value$type, "polars_lazy_frame")) + } + } + rows[[length(rows) + 1L]] <- data.frame(mode = mode, package = package, + pass = pass, elapsed_ms = 1000 * elapsed, + bytes = as.numeric(utils::object.size(result))) + } + } + results <- do.call(rbind, rows) + utils::write.csv(results, output, row.names = FALSE) + print(aggregate(elapsed_ms ~ mode, results, stats::median), row.names = FALSE) +} +run()