From bc54dd54cb73895c2df10f639a046c7f1f5bf182 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Fri, 9 Oct 2026 00:30:17 +0800 Subject: [PATCH 1/5] Stabilize inlay hints during incomplete edits --- NEWS.md | 4 + R/document.R | 26 +++- R/inlay_hint.R | 120 +++++++++++++++- R/provider_index.R | 10 +- tests/testthat/test-inlay-hint.R | 235 +++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 11 deletions(-) diff --git a/NEWS.md b/NEWS.md index 098fe1bb..bf50fd6d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # languageserver 0.3.20 +- Keep parameter-name inlay hints for untouched calls during incomplete edits, + moving their positions with incremental changes and refreshing after a valid + parse, so a temporary syntax error does not clear hints across the document. + - Complete chained `$` members using static receiver analysis, including source list/environment factories, declarative R6 classes and package method registries. Package metadata is prepared in a background worker; completion diff --git a/R/document.R b/R/document.R index 9f7692fe..c30ebedd 100644 --- a/R/document.R +++ b/R/document.R @@ -9,6 +9,8 @@ Document <- R6::R6Class( content = NULL, call_scan_cache = NULL, parse_data = NULL, + inlay_hint_data = NULL, + inlay_hint_formals = NULL, is_rmarkdown = NULL, regions = NULL, loaded_packages = NULL, @@ -35,7 +37,10 @@ Document <- R6::R6Class( self$is_open <- FALSE }, - set_content = function(version, content) { + set_content = function(version, content, change = NULL) { + if (!identical(self$content, content)) { + self$inlay_hint_data <- inlay_hint_apply_change(self$inlay_hint_data, change, self$content) + } self$version <- version self$nline <- length(content) self$content <- content @@ -94,12 +99,18 @@ Document <- R6::R6Class( } else { character() } - self$set_content(version, c(before, changed, after)) + self$set_content(version, c(before, changed, after), change) } }, update_parse_data = function(parse_data) { self$parse_data <- parse_data + if (!isTRUE(parse_data$parse_error) && !isTRUE(parse_data$inlay_hint_incomplete)) { + self$inlay_hint_data <- parse_data$range_data[c( + "calls", "arguments", "argument_order", "argument_lines" + )] + self$inlay_hint_formals <- new.env(parent = emptyenv()) + } }, line = function(row) { @@ -554,7 +565,14 @@ get_content_hash <- function(content) { parse_document <- function(uri, content, is_rmarkdown = FALSE, content_hash = NULL) { - content <- normalize_parse_content(content, is_rmarkdown) + parse_content <- normalize_parse_content(content, is_rmarkdown) + inlay_hint_incomplete_lines <- if (is_rmarkdown) { + which(parse_content != normalize_parse_content( + content, is_rmarkdown, parseable_only = FALSE)) - 1L + } else { + integer() + } + content <- parse_content if (is.null(content_hash)) content_hash <- get_content_hash(content) parse_env <- function() { @@ -578,6 +596,8 @@ parse_document <- function(uri, content, is_rmarkdown = FALSE, NULL, content, env$completion_data, uri, env$definitions) env$content_hash <- content_hash env$parse_error <- FALSE + env$inlay_hint_incomplete <- length(inlay_hint_incomplete_lines) > 0L + env$inlay_hint_incomplete_lines <- inlay_hint_incomplete_lines env } env <- parse_env() diff --git a/R/inlay_hint.R b/R/inlay_hint.R index 12e5f6e1..6d7d3785 100644 --- a/R/inlay_hint.R +++ b/R/inlay_hint.R @@ -47,17 +47,107 @@ match_named_formal <- function(name, formal_names) { if (length(partial) == 1L) partial else NA_integer_ } +#' Move untouched calls with an incremental edit, discarding intersecting calls +#' @noRd +inlay_hint_apply_change <- function(indexed, change, content) { + if (is.null(indexed) || is.null(change$range)) return(NULL) + calls <- indexed$calls + if (!length(calls$name)) return(indexed) + start <- change$range$start + end <- change$range$end + before <- calls$end_line < start$line | + calls$end_line == start$line & calls$end_col <= start$character + after <- calls$line > end$line | + calls$line == end$line & calls$col >= end$character + # Text inserted at the callee boundary can change its name or turn it + # into a member call. Retain that call only for whitespace insertions. + at_start <- calls$line == start$line & calls$col == start$character + if (identical(start, end) && grepl("[^[:space:]]", change$text)) { + after[at_start] <- FALSE + } + # An edit outside a call can still put it in a comment, string or + # backtick name. Drop following calls when lexical context may change. + line_text <- function(row) if (row < length(content)) content[[row + 1L]] else "" + removed <- get_range_text( + content, start$line + 1L, + code_point_from_unit(line_text(start$line), start$character) + 1L, + end$line + 1L, + code_point_from_unit(line_text(end$line), end$character) + ) + if (any(grepl("[#\"'`\\\\]", c(change$text, removed), useBytes = TRUE), na.rm = TRUE)) { + after[] <- FALSE + } + keep <- before | after + if (!any(keep)) return(NULL) + + replacement <- stringi::stri_split_lines(change$text)[[1L]] + new_end_line <- start$line + length(replacement) - 1L + new_end_col <- code_point_to_unit(utils::tail(replacement, 1L), Inf) + if (length(replacement) == 1L) new_end_col <- new_end_col + start$character + shift <- function(lines, cols, move) { + same_line <- move & !is.na(lines) & lines == end$line + cols[same_line] <- cols[same_line] + new_end_col - end$character + lines[move] <- lines[move] + new_end_line - end$line + list(line = lines, col = cols) + } + for (fields in list(c("line", "col"), c("end_line", "end_col"))) { + moved <- shift(calls[[fields[[1L]]]], calls[[fields[[2L]]]], after) + calls[[fields[[1L]]]] <- moved$line + calls[[fields[[2L]]]] <- moved$col + } + arguments <- indexed$arguments + moved <- shift(arguments$line, arguments$col, after[arguments$call]) + arguments$line <- moved$line + arguments$col <- moved$col + + kept_arguments <- which(keep[arguments$call]) + arguments <- lapply(arguments, `[`, kept_arguments) + arguments$call <- match(arguments$call, which(keep)) + calls <- lapply(calls, `[`, which(keep)) + calls$first_argument <- match(calls$first_argument, kept_arguments) + calls$last_argument <- match(calls$last_argument, kept_arguments) + indexed$calls <- calls + indexed$arguments <- arguments + indexed$argument_order <- which(arguments$present) + indexed$argument_order <- indexed$argument_order[order( + arguments$line[indexed$argument_order], arguments$col[indexed$argument_order] + )] + indexed$argument_lines <- arguments$line[indexed$argument_order] + indexed +} + #' Extract parameter-name inlay hints for calls in a requested range #' @noRd inlay_hint_reply <- function(id, uri, workspace, document, request_range) { parse_data <- current_parse_data(uri, workspace, document) if (is.null(parse_data)) return(NULL) + incomplete <- isTRUE(parse_data$parse_error) || isTRUE(parse_data$inlay_hint_incomplete) + if (incomplete && !is.null(document$inlay_hint_data)) { + previous <- indexed_inlay_hint_reply( + id, uri, workspace, document$inlay_hint_data, request_range, + document$inlay_hint_formals, use_previous_formals = TRUE, + regions = document$regions, + fallback_lines = if (isTRUE(parse_data$parse_error)) NULL else + parse_data$inlay_hint_incomplete_lines + ) + if (isTRUE(parse_data$parse_error)) return(previous) + # Literate documents still parse their other R cells successfully. + current <- indexed_inlay_hint_reply( + id, uri, workspace, parse_data$range_data, request_range + ) + hints <- c(current$result, previous$result) + positions <- vapply(hints, function(hint) { + paste(hint$position$line, hint$position$character, sep = ":") + }, character(1L)) + return(Response$new(id, result = utils::head(hints[!duplicated(positions)], 200L))) + } xdoc <- parse_data$xml_doc if (is.null(xdoc)) return(Response$new(id, result = list())) if (!is.null(parse_data$range_data)) { return(indexed_inlay_hint_reply( - id, uri, workspace, parse_data$range_data, request_range + id, uri, workspace, parse_data$range_data, request_range, + document$inlay_hint_formals )) } @@ -177,7 +267,9 @@ inlay_hint_reply <- function(id, uri, workspace, document, request_range) { #' Resolve hints only for calls with an argument visible in the viewport #' @noRd -indexed_inlay_hint_reply <- function(id, uri, workspace, indexed, request_range) { +indexed_inlay_hint_reply <- function(id, uri, workspace, indexed, request_range, + previous_formals = NULL, use_previous_formals = FALSE, regions = NULL, + fallback_lines = NULL) { arguments <- indexed$arguments visible <- indexed$argument_order[range_line_indices( indexed$argument_lines, @@ -186,6 +278,12 @@ indexed_inlay_hint_reply <- function(id, uri, workspace, indexed, request_range) visible <- visible[range_position_selected( arguments$line[visible], arguments$col[visible], request_range )] + if (!is.null(regions)) { + visible <- visible[regions$line_type[arguments$line[visible] + 1L] == "r"] + } + if (!is.null(fallback_lines)) { + visible <- visible[arguments$line[visible] %in% fallback_lines] + } if (!length(visible)) return(Response$new(id, result = list())) calls <- indexed$calls call_indices <- sort(unique(arguments$call[visible])) @@ -215,10 +313,20 @@ indexed_inlay_hint_reply <- function(id, uri, workspace, indexed, request_range) if (exists(cache_key, envir = formals_cache, inherits = FALSE)) { formal_names <- get(cache_key, envir = formals_cache, inherits = FALSE) } else { - function_formals <- tryCatch(call_with_optional_uri( - workspace$get_formals, function_name, package, uri = uri), - error = function(e) NULL) - formal_names <- names(function_formals) + formal_names <- if (use_previous_formals && !is.null(previous_formals)) { + get0(cache_key, envir = previous_formals, inherits = FALSE) + } else { + NULL + } + if (is.null(formal_names)) { + function_formals <- tryCatch(call_with_optional_uri( + workspace$get_formals, function_name, package, uri = uri), + error = function(e) NULL) + formal_names <- names(function_formals) + if (!use_previous_formals && !is.null(previous_formals)) { + assign(cache_key, formal_names, envir = previous_formals) + } + } assign(cache_key, formal_names, envir = formals_cache) } if (!length(formal_names)) next diff --git a/R/provider_index.R b/R/provider_index.R index 01d92f69..f79e46a4 100644 --- a/R/provider_index.R +++ b/R/provider_index.R @@ -8,7 +8,9 @@ range_provider_parse_data <- function(data, content) { ), calls = list( name = character(), package = character(), - first_argument = integer(), last_argument = integer() + first_argument = integer(), last_argument = integer(), + line = integer(), col = integer(), + end_line = integer(), end_col = integer() ), arguments = list( call = integer(), line = integer(), col = integer(), @@ -57,7 +59,11 @@ range_provider_parse_data <- function(data, content) { name = data$text[index[["function"]]], package = text_at(index$package), first_argument = index$first_argument, - last_argument = index$last_argument + last_argument = index$last_argument, + line = as.integer(data$line1[index$call] - 1L), + col = convert_columns(data$line1[index$call] - 1L, data$col1[index$call] - 1L), + end_line = as.integer(data$line2[index$call] - 1L), + end_col = convert_columns(data$line2[index$call] - 1L, data$col2[index$call]) ) first <- index$argument_first present <- first > 0L diff --git a/tests/testthat/test-inlay-hint.R b/tests/testthat/test-inlay-hint.R index 90672d3a..6237040b 100644 --- a/tests/testthat/test-inlay-hint.R +++ b/tests/testthat/test-inlay-hint.R @@ -168,6 +168,18 @@ test_that("inlay hints work through the language server", { resolved <- respond(client, "inlayHint/resolve", hints[[1L]]) expect_match(resolved$tooltip$value, "```r\\nrnorm\\(") expect_match(resolved$tooltip$value, "`mean` - vector of means") + + notify(client, "textDocument/didChange", list( + textDocument = list(uri = path_to_uri(path), version = 2L), + contentChanges = list(list( + range = range(position(0L, 22L), position(0L, 22L)), text = "\nother(" + )) + )) + preserved <- respond(client, "textDocument/inlayHint", list( + textDocument = list(uri = path_to_uri(path)), + range = range(position(0L, 0L), position(2L, 0L)) + ), retry = FALSE) + expect_equal(preserved, hints) }) test_that("inlay hint helpers handle malformed and empty calls", { @@ -287,3 +299,226 @@ test_that("inlay hint resolution tolerates missing metadata and documentation", "Parameter `parameter` of `pkg::target()`." ) }) + +inlay_hint_edit <- function(fixture, version, changes) { + document <- fixture$document + document$apply_content_changes(version, changes) + parsed <- parse_document(fixture$uri, document$content, document$is_rmarkdown) + parsed$version <- version + parsed$xml_doc <- xml2::read_xml(parsed$xml_data) + document$update_parse_data(parsed) + parsed +} + +test_that("inlay hints survive unrelated incomplete edits and refresh after parsing", { + fixture <- provider_fixture( + c("target(one, two)", "", "target(three, four)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + hints <- function() { + inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + } + original <- hints() + expect_length(original, 4L) + + parsed <- inlay_hint_edit(fixture, 2L, list(list( + range = range(position(1L, 0L), position(1L, 0L)), text = "other(" + ))) + expect_true(parsed$parse_error) + expect_identical(hints(), original) + + parsed <- inlay_hint_edit(fixture, 3L, list(list( + range = range(position(1L, 6L), position(1L, 6L)), text = "one, two)" + ))) + expect_false(parsed$parse_error) + expect_length(hints(), 6L) + + parsed <- inlay_hint_edit(fixture, 4L, list(list( + range = range(position(0L, 0L), position(3L, 0L)), text = "" + ))) + expect_false(parsed$parse_error) + expect_length(hints(), 0L) +}) + +test_that("inlay hints move untouched calls through sequential Unicode edits", { + fixture <- provider_fixture( + c('label <- "\U0001f600"; target(one, two)', "", "target(, two, three)"), + function(...) formals(function(first, second, third) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + hints <- function(request_range = request) { + inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request_range + )$result + } + original <- hints() + expect_length(original, 4L) + + parsed <- inlay_hint_edit(fixture, 2L, list( + list(range = range(position(0L, 0L), position(0L, 0L)), text = "\n"), + list(range = range(position(1L, 0L), position(1L, 0L)), text = " "), + list(range = range(position(2L, 0L), position(2L, 0L)), text = "other(") + )) + expect_true(parsed$parse_error) + expected <- original + for (i in seq_along(expected)) { + expected[[i]]$position$line <- expected[[i]]$position$line + 1L + if (expected[[i]]$position$line == 1L) { + expected[[i]]$position$character <- expected[[i]]$position$character + 2L + } + } + expect_equal(hints(), expected) + expect_equal(hints(range(position(3L, 0L), position(4L, 0L))), expected[3:4]) + expect_equal(hints(range(position(0L, 0L), position(1L, 0L))), list()) + + parsed <- inlay_hint_edit(fixture, 3L, list(list( + range = range(position(0L, 0L), position(1L, 2L)), text = "\U00010400 <- 1; " + ))) + expect_true(parsed$parse_error) + for (i in seq_along(expected)) { + expected[[i]]$position$line <- expected[[i]]$position$line - 1L + if (expected[[i]]$position$line == 0L) { + expected[[i]]$position$character <- expected[[i]]$position$character + 7L + } + } + expect_equal(hints(), expected) +}) + +test_that("inlay hints discard changed outer calls while retaining untouched nested calls", { + fixture <- provider_fixture( + c("target(one, inner(two, three))", "", "target(four, five)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + hints <- function() { + inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + } + original <- hints() + expect_length(original, 6L) + parsed <- inlay_hint_edit(fixture, 2L, list(list( + range = range(position(0L, 10L), position(0L, 10L)), text = " +" + ))) + expect_true(parsed$parse_error) + expected <- original[3:6] + expected[[1L]]$position$character <- expected[[1L]]$position$character + 2L + expected[[2L]]$position$character <- expected[[2L]]$position$character + 2L + expect_equal(hints(), expected) + + parsed <- inlay_hint_edit(fixture, 3L, list(list( + range = range(position(2L, 0L), position(2L, 0L)), text = "object$" + ))) + expect_true(parsed$parse_error) + expect_equal(hints(), expected[1:2]) + + inlay_hint_edit(fixture, 4L, list(list( + range = range(position(0L, 17L), position(0L, 22L)), text = "other" + ))) + expect_length(hints(), 0L) +}) + +test_that("inlay fallback retains local parameter names and honors settings", { + available <- TRUE + fixture <- provider_fixture( + c("target <- function(first, second) NULL", "target(one, two)", ""), + function(...) if (available) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + hints <- function() { + inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + } + original <- hints() + available <- FALSE + parsed <- inlay_hint_edit(fixture, 2L, list(list( + range = range(position(2L, 0L), position(2L, 0L)), text = "other(" + ))) + expect_true(parsed$parse_error) + expect_identical(hints(), original) + + old_minimum <- lsp_settings$get("inlay_hints_minimum_arguments") + withr::defer(lsp_settings$set("inlay_hints_minimum_arguments", old_minimum)) + lsp_settings$set("inlay_hints_minimum_arguments", 3L) + expect_length(hints(), 0L) + lsp_settings$set("inlay_hints_minimum_arguments", old_minimum) + + inlay_hint_edit(fixture, 3L, list(list( + range = range(position(2L, 6L), position(2L, 6L)), text = ")" + ))) + expect_length(hints(), 0L) +}) + +test_that("inlay fallback clears on full replacement and waits for current parsing", { + fixture <- provider_fixture("target(one, two)", function(...) formals(function(first, second) NULL)) + request <- range(position(0L, 0L), position(1L, 0L)) + fixture$document$apply_content_changes(2L, list(list( + range = range(position(0L, 16L), position(0L, 16L)), text = " +" + ))) + expect_null(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )) + parsed <- inlay_hint_edit(fixture, 3L, list(list(text = "target("))) + expect_true(parsed$parse_error) + expect_null(fixture$document$inlay_hint_data) + expect_length(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, 0L) +}) + +test_that("inlay fallback drops calls affected by comments and strings", { + for (text in c("#", "\"", "'", "`")) { + fixture <- provider_fixture( + c("target(one, two)", "", "target(three, four)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + original <- inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + inlay_hint_edit(fixture, 2L, list(list( + range = range(position(1L, 0L), position(1L, 0L)), text = paste0("other(", text) + ))) + expect_equal(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, original[1:2]) + } +}) + +test_that("inlay fallback preserves hints in an incomplete literate R cell", { + fixture <- provider_fixture("", function(...) formals(function(first, second) NULL)) + fixture$document <- Document$new(fixture$uri, language = "quarto", version = 1L, + content = c("```{r}", "target(one, two)", "", "```", + "```{r}", "target(three, four)", "```")) + fixture$workspace$documents$set(fixture$uri, fixture$document) + inlay_hint_edit(fixture, 1L, list()) + request <- range(position(0L, 0L), position(20L, 0L)) + hints <- function() { + inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + } + original <- hints() + expect_length(original, 4L) + parsed <- inlay_hint_edit(fixture, 2L, list(list( + range = range(position(2L, 0L), position(2L, 0L)), text = "other(" + ))) + expect_false(parsed$parse_error) + expect_true(parsed$inlay_hint_incomplete) + expect_equal(hints()[order(vapply(hints(), function(hint) hint$position$line, integer(1L)))], + original) + + fixture$workspace$get_formals <- function(...) formals(function(first) NULL) + expect_equal(hints(), c(list(original[[3L]]), original[1:2])) + fixture$workspace$get_formals <- function(...) formals(function(first, second) NULL) + + # Changing the engine must not leave R hints in a Python cell. + inlay_hint_edit(fixture, 3L, list(list( + range = range(position(0L, 4L), position(0L, 5L)), text = "python" + ))) + expect_equal(hints(), original[3:4]) +}) From 95d62ac8b7764f360a9a58f9c31cfc009e8b6729 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 10 Oct 2026 08:30:29 +0800 Subject: [PATCH 2/5] Invalidate inlay hints when prefix edits change a callee --- R/inlay_hint.R | 9 ++-- tests/testthat/test-inlay-hint.R | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/R/inlay_hint.R b/R/inlay_hint.R index 6d7d3785..41ea3181 100644 --- a/R/inlay_hint.R +++ b/R/inlay_hint.R @@ -59,10 +59,11 @@ inlay_hint_apply_change <- function(indexed, change, content) { calls$end_line == start$line & calls$end_col <= start$character after <- calls$line > end$line | calls$line == end$line & calls$col >= end$character - # Text inserted at the callee boundary can change its name or turn it - # into a member call. Retain that call only for whitespace insertions. - at_start <- calls$line == start$line & calls$col == start$character - if (identical(start, end) && grepl("[^[:space:]]", change$text)) { + # Inserts, replacements and deletions ending at a callee can change its + # name or turn it into a member call. Only retain whitespace replacements + # here; an empty replacement can join the callee to preceding text. + at_start <- calls$line == end$line & calls$col == end$character + if (!nzchar(change$text) || grepl("[^[:space:]]", change$text)) { after[at_start] <- FALSE } # An edit outside a call can still put it in a comment, string or diff --git a/tests/testthat/test-inlay-hint.R b/tests/testthat/test-inlay-hint.R index 6237040b..f29e0827 100644 --- a/tests/testthat/test-inlay-hint.R +++ b/tests/testthat/test-inlay-hint.R @@ -421,6 +421,79 @@ test_that("inlay hints discard changed outer calls while retaining untouched nes expect_length(hints(), 0L) }) +test_that("inlay fallback drops calls when prefix replacements change their callee", { + for (prefix in c("obj$", "obj@", "pkg::", "renamed", "obj$\n")) { + fixture <- provider_fixture( + c("x <- target(one, two)", "", "target(three, four)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + original <- inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + expect_length(original, 4L) + # A second edit can fail parsing before the prefix replacement has + # produced a successful parse and replaced the cached call index. + fixture$document$apply_content_changes(2L, list(list( + range = range(position(0L, 0L), position(0L, 5L)), text = prefix + ))) + added_lines <- length(stringi::stri_split_lines(prefix)[[1L]]) - 1L + parsed <- inlay_hint_edit(fixture, 3L, list(list( + range = range(position(1L + added_lines, 0L), position(1L + added_lines, 0L)), + text = "other(" + ))) + expect_true(parsed$parse_error) + expected <- original[3:4] + for (i in seq_along(expected)) { + expected[[i]]$position$line <- expected[[i]]$position$line + added_lines + } + expect_equal(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, expected, info = prefix) + } +}) + +test_that("inlay fallback treats deletions and whitespace at callee boundaries conservatively", { + fixture <- provider_fixture( + c("x <- target(one, two)", "", "target(three, four)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + original <- inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + expect_length(original, 4L) + fixture$document$apply_content_changes(2L, list(list( + range = range(position(0L, 0L), position(0L, 5L)), text = "obj$ " + ))) + parsed <- inlay_hint_edit(fixture, 3L, list( + list(range = range(position(0L, 5L), position(0L, 6L)), text = ""), + list(range = range(position(1L, 0L), position(1L, 0L)), text = "other(") + )) + expect_true(parsed$parse_error) + expect_equal(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, original[3:4]) + + fixture <- provider_fixture( + c("x <- target(one, two)", "", "target(three, four)"), + function(...) formals(function(first, second) NULL) + ) + original <- inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + parsed <- inlay_hint_edit(fixture, 2L, list( + list(range = range(position(0L, 0L), position(0L, 5L)), text = " "), + list(range = range(position(1L, 0L), position(1L, 0L)), text = "other(") + )) + expect_true(parsed$parse_error) + original[[1L]]$position$character <- original[[1L]]$position$character - 3L + original[[2L]]$position$character <- original[[2L]]$position$character - 3L + expect_equal(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, original) +}) + test_that("inlay fallback retains local parameter names and honors settings", { available <- TRUE fixture <- provider_fixture( From 4acefe9d7c651b0ab755b47c8da5070427db80ca Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 10 Oct 2026 09:24:34 +0800 Subject: [PATCH 3/5] Invalidate inlay hints across whitespace before callees --- R/inlay_hint.R | 18 ++++++++--- tests/testthat/test-inlay-hint.R | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/R/inlay_hint.R b/R/inlay_hint.R index 41ea3181..11f07ec9 100644 --- a/R/inlay_hint.R +++ b/R/inlay_hint.R @@ -59,16 +59,26 @@ inlay_hint_apply_change <- function(indexed, change, content) { calls$end_line == start$line & calls$end_col <= start$character after <- calls$line > end$line | calls$line == end$line & calls$col >= end$character + line_text <- function(row) if (row < length(content)) content[[row + 1L]] else "" # Inserts, replacements and deletions ending at a callee can change its - # name or turn it into a member call. Only retain whitespace replacements - # here; an empty replacement can join the callee to preceding text. - at_start <- calls$line == end$line & calls$col == end$character + # name or turn it into a member call, even with spaces or tabs in between. + # Only retain whitespace replacements here; an empty replacement can + # join the callee to preceding text. if (!nzchar(change$text) || grepl("[^[:space:]]", change$text)) { + end_text <- line_text(end$line) + end_col <- code_point_from_unit(end_text, end$character) + following <- substring(end_text, end_col + 1L) + first_token <- regexpr("[^[:space:]]", following)[[1L]] + boundary <- if (first_token > 0L) { + code_point_to_unit(end_text, end_col + first_token - 1L) + } else { + end$character + } + at_start <- calls$line == end$line & calls$col == boundary after[at_start] <- FALSE } # An edit outside a call can still put it in a comment, string or # backtick name. Drop following calls when lexical context may change. - line_text <- function(row) if (row < length(content)) content[[row + 1L]] else "" removed <- get_range_text( content, start$line + 1L, code_point_from_unit(line_text(start$line), start$character) + 1L, diff --git a/tests/testthat/test-inlay-hint.R b/tests/testthat/test-inlay-hint.R index f29e0827..a0df2ed6 100644 --- a/tests/testthat/test-inlay-hint.R +++ b/tests/testthat/test-inlay-hint.R @@ -494,6 +494,58 @@ test_that("inlay fallback treats deletions and whitespace at callee boundaries c )$result, original) }) +test_that("inlay fallback drops callees after whitespace-separated prefix edits", { + cases <- expand.grid( + prefix = c("obj$", "obj@", "pkg::"), + separator = c(" ", " ", "\t"), + assignment = c("x <-", "\U00010400 <-"), + stringsAsFactors = FALSE + ) + for (i in seq_len(nrow(cases))) { + case <- cases[i, ] + fixture <- provider_fixture( + c(paste0(case$assignment, case$separator, "target(one, two)"), + "", "target(three, four)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + original <- inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + expect_length(original, 4L) + fixture$document$apply_content_changes(2L, list(list( + range = range(position(0L, 0L), position(0L, ncodeunit(case$assignment))), + text = case$prefix + ))) + parsed <- inlay_hint_edit(fixture, 3L, list(list( + range = range(position(1L, 0L), position(1L, 0L)), text = "other(" + ))) + expect_true(parsed$parse_error) + expect_equal(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, original[3:4], info = paste(case, collapse = "; ")) + } +}) + +test_that("inlay fallback preserves a call after intervening tokens on the edited line", { + fixture <- provider_fixture( + c("x <- one; target(two, three)", "", "target(four, five)"), + function(...) formals(function(first, second) NULL) + ) + request <- range(position(0L, 0L), position(20L, 0L)) + original <- inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result + parsed <- inlay_hint_edit(fixture, 2L, list( + list(range = range(position(0L, 0L), position(0L, 4L)), text = "obj$"), + list(range = range(position(1L, 0L), position(1L, 0L)), text = "other(") + )) + expect_true(parsed$parse_error) + expect_equal(inlay_hint_reply( + 1L, fixture$uri, fixture$workspace, fixture$document, request + )$result, original) +}) + test_that("inlay fallback retains local parameter names and honors settings", { available <- TRUE fixture <- provider_fixture( From 7a203c70c8c3dffeccd0947244ab3fc220599d56 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 10 Oct 2026 09:42:47 +0800 Subject: [PATCH 4/5] Use a portable Unicode fixture for inlay hint edits --- tests/testthat/test-inlay-hint.R | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-inlay-hint.R b/tests/testthat/test-inlay-hint.R index a0df2ed6..9325117b 100644 --- a/tests/testthat/test-inlay-hint.R +++ b/tests/testthat/test-inlay-hint.R @@ -498,23 +498,27 @@ test_that("inlay fallback drops callees after whitespace-separated prefix edits" cases <- expand.grid( prefix = c("obj$", "obj@", "pkg::"), separator = c(" ", " ", "\t"), - assignment = c("x <-", "\U00010400 <-"), + # Keep the quoted Unicode name outside the edit so this tests UTF-16 + # offsets without activating the backtick invalidation rule. + before = c("", "`\U00010400` <- NULL; "), stringsAsFactors = FALSE ) for (i in seq_len(nrow(cases))) { case <- cases[i, ] fixture <- provider_fixture( - c(paste0(case$assignment, case$separator, "target(one, two)"), + c(paste0(case$before, "x <-", case$separator, "target(one, two)"), "", "target(three, four)"), function(...) formals(function(first, second) NULL) ) request <- range(position(0L, 0L), position(20L, 0L)) + expect_false(fixture$document$parse_data$parse_error) original <- inlay_hint_reply( 1L, fixture$uri, fixture$workspace, fixture$document, request )$result expect_length(original, 4L) fixture$document$apply_content_changes(2L, list(list( - range = range(position(0L, 0L), position(0L, ncodeunit(case$assignment))), + range = range(position(0L, ncodeunit(case$before)), + position(0L, ncodeunit(paste0(case$before, "x <-")))), text = case$prefix ))) parsed <- inlay_hint_edit(fixture, 3L, list(list( From e737372a5604245ea8502441599e365c608285e1 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sat, 10 Oct 2026 10:14:39 +0800 Subject: [PATCH 5/5] Allow coverage overhead while loading source closures --- tests/testthat/test-index.R | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/testthat/test-index.R b/tests/testthat/test-index.R index f9457844..1966f209 100644 --- a/tests/testthat/test-index.R +++ b/tests/testthat/test-index.R @@ -218,7 +218,10 @@ test_that("plain projects load source closures without merging unrelated scripts client <- language_client(root) client %>% did_open(main) - sourced <- client %>% respond_signature(main, c(1, 11), + # Both the main file and its source closure must finish background parsing. + # Coverage instrumentation slows these workers during parallel tests. + timeout <- if (identical(Sys.getenv("R_COVR"), "true")) 60 else 10 + sourced <- client %>% respond_signature(main, c(1, 11), timeout = timeout, retry_when = function(result) length(result$signatures) == 0L) expect_length(sourced$signatures, 1L) expect_match(sourced$signatures[[1L]]$label, "helper_fun\\(value")