diff --git a/R/capabilities.R b/R/capabilities.R index 9422db0b..724acc74 100644 --- a/R/capabilities.R +++ b/R/capabilities.R @@ -76,7 +76,7 @@ ServerCapabilities <- list( documentSymbolProvider = TRUE, workspaceSymbolProvider = TRUE, codeActionProvider = TRUE, - # codeLensProvider = CodeLensOptions, + codeLensProvider = CodeLensOptions, documentFormattingProvider = TRUE, documentRangeFormattingProvider = TRUE, documentOnTypeFormattingProvider = DocumentOnTypeFormattingOptions, diff --git a/R/code_lens.R b/R/code_lens.R new file mode 100644 index 00000000..6b4f6f50 --- /dev/null +++ b/R/code_lens.R @@ -0,0 +1,217 @@ +#' Count references to a symbol +#' +#' Counts how many times a symbol is referenced across all documents. +#' References are symbol uses (SYMBOL_FUNCTION_CALL) or variables (SYMBOL) that are not the definition. +#' @noRd +count_references <- function(token_name, workspace) { + count <- 0 + + # First, find where the definition is + def_uri <- NULL + def_line_start <- NULL + def_line_end <- NULL + + for (doc_uri in workspace$documents$keys()) { + defns <- workspace$get_definitions_for_uri(doc_uri) + if (!is.null(defns[[token_name]])) { + def_uri <- doc_uri + def_line_start <- defns[[token_name]]$range$start$line + def_line_end <- defns[[token_name]]$range$end$line + break + } + } + + if (is.null(def_uri)) { + return(0) + } + + # Count SYMBOL_FUNCTION_CALL occurrences (these are definitely references) + for (doc_uri in workspace$documents$keys()) { + xdoc <- workspace$get_parse_data(doc_uri)$xml_doc + if (!is.null(xdoc)) { + # Find all SYMBOL_FUNCTION_CALL nodes, then filter by name in R + all_calls <- xml_find_all(xdoc, "//SYMBOL_FUNCTION_CALL") + matching_calls <- all_calls[xml_text(all_calls) == token_name] + count <- count + length(matching_calls) + + # For the definition document, also count SYMBOL nodes that are not in the definition + if (doc_uri == def_uri) { + all_symbols <- xml_find_all(xdoc, "//SYMBOL") + symbols <- all_symbols[xml_text(all_symbols) == token_name] + line1 <- as.integer(xml_attr(symbols, "line1")) + + for (i in seq_len(length(symbols))) { + symbol_line <- line1[[i]] - 1 + # Skip if it's part of the definition + if (symbol_line < def_line_start || symbol_line > def_line_end) { + count <- count + 1 + } + } + } + } + } + + count +} + + +#' Find test files that reference a symbol +#' +#' Detects which test files contain references to a given symbol +#' @noRd +find_test_coverage <- function(token_name, workspace) { + token_quote <- xml_single_quote(token_name) + test_files <- c() + + for (doc_uri in workspace$documents$keys()) { + # Check if this is a test file (loose pattern matching) + uri_lower <- tolower(doc_uri) + if (!grepl("test", uri_lower, fixed = TRUE)) { + next + } + + xdoc <- workspace$get_parse_data(doc_uri)$xml_doc + + if (!is.null(xdoc)) { + # Look for references to the function + references_xpath <- glue("//*[(self::SYMBOL or self::SYMBOL_FUNCTION_CALL) and text() = '{token_quote}']") + symbols <- xml_find_all(xdoc, references_xpath) + + if (length(symbols) > 0) { + test_files <- c(test_files, doc_uri) + } + } + } + + # Return unique test files + unique(test_files) +} + + +#' Find method implementations for a generic function +#' +#' Detects S3 method implementations for a given generic function name +#' @noRd +find_method_implementations <- function(generic_name, workspace) { + implementations <- 0 + + # S3 method pattern: generic_name.something + s3_pattern <- paste0("^", generic_name, "\\.[a-zA-Z0-9._]+$") + + for (doc_uri in workspace$documents$keys()) { + xdoc <- workspace$get_parse_data(doc_uri)$xml_doc + + if (!is.null(xdoc)) { + # Find all function assignments by looking at SYMBOL_FORMALS + # A SYMBOL_FORMALS that matches the S3 pattern is a method implementation + all_formals <- xml_find_all(xdoc, "//SYMBOL_FORMALS") + + for (formal in all_formals) { + formal_name <- xml_text(formal) + # Check for S3 method pattern + if (grepl(s3_pattern, formal_name)) { + implementations <- implementations + 1 + } + } + } + } + + implementations +} + + +#' Generate code lens for document +#' +#' Creates CodeLens objects showing reference count, test coverage, and method implementations +#' Returns NULL if parse_data is not yet available (document still parsing) +#' @noRd +code_lens_reply <- function(id, uri, workspace, document) { + result <- list() + + if (is.null(document)) { + return(Response$new(id, result = result)) + } + + # Check if parse_data is available + parse_data <- workspace$get_parse_data(uri) + if (is.null(parse_data)) { + # Document still parsing, return NULL to queue for retry + return(NULL) + } + + # Get all definitions for this document using workspace's built-in method + definitions <- workspace$get_definitions_for_uri(uri) + + # Iterate over each definition + for (symbol_name in names(definitions)) { + defn <- definitions[[symbol_name]] + + if (is.null(defn) || is.null(defn$range)) { + next + } + + # Reconstruct the range as a plain list to avoid serialization issues + symbol_range <- defn$range + lens_range <- list( + start = list( + line = as.numeric(symbol_range$start$line), + character = as.numeric(symbol_range$start$character) + ), + end = list( + line = as.numeric(symbol_range$end$line), + character = as.numeric(symbol_range$end$character) + ) + ) + + # 1. Reference count (always show, even if 0) + ref_count <- count_references(symbol_name, workspace) + ref_label <- if (ref_count == 1) "1 reference" else paste0(ref_count, " references") + result <- c(result, list(list( + range = lens_range, + command = list( + title = ref_label, + command = "" + ) + ))) + + # 2. Test coverage + test_files <- find_test_coverage(symbol_name, workspace) + if (length(test_files) > 0) { + test_label <- if (length(test_files) == 1) "1 test file" else paste0(length(test_files), " test files") + result <- c(result, list(list( + range = lens_range, + command = list( + title = test_label, + command = "" + ) + ))) + } + + # 3. Method implementations (for S3 generics) + method_count <- find_method_implementations(symbol_name, workspace) + if (method_count > 0) { + method_label <- if (method_count == 1) "1 method" else paste0(method_count, " methods") + result <- c(result, list(list( + range = lens_range, + command = list( + title = method_label, + command = "" + ) + ))) + } + } + + Response$new(id, result = result) +} + + +#' Resolve code lens (called after initial request) +#' +#' Provides additional details about a code lens entry +#' @noRd +code_lens_resolve_reply <- function(id, workspace, code_lens) { + # The code lens command is already populated in the initial request + # This handler returns the lens as-is; it could be enhanced with more details + Response$new(id, result = code_lens) +} + diff --git a/R/handlers-langfeatures.R b/R/handlers-langfeatures.R index f4782ef0..e713aadc 100644 --- a/R/handlers-langfeatures.R +++ b/R/handlers-langfeatures.R @@ -145,7 +145,20 @@ text_document_code_action <- function(self, id, params) { #' Handler to the `textDocument/codeLens` [Request]. #' @noRd text_document_code_lens <- function(self, id, params) { - + textDocument <- params$textDocument + uri <- uri_escape_unicode(textDocument$uri) + document <- self$workspace$documents$get(uri) + reply <- code_lens_reply(id, uri, self$workspace, document) + if (is.null(reply)) { + queue <- self$pending_replies$get(uri)[["textDocument/codeLens"]] + queue$push(list( + id = id, + version = document$version, + params = params + )) + } else { + self$deliver(reply) + } } #' `codeLens/resolve` request handler @@ -153,7 +166,7 @@ text_document_code_lens <- function(self, id, params) { #' Handler to the `codeLens/resolve` [Request]. #' @noRd code_lens_resolve <- function(self, id, params) { - + self$deliver(code_lens_resolve_reply(id, self$workspace, params)) } diff --git a/R/languageserver.R b/R/languageserver.R index 5e8d1681..9b8ce204 100644 --- a/R/languageserver.R +++ b/R/languageserver.R @@ -104,6 +104,7 @@ LanguageServer <- R6::R6Class("LanguageServer", if (!self$pending_replies$has(uri)) { self$pending_replies$set(uri, list( + `textDocument/codeLens` = collections::queue(), `textDocument/documentSymbol` = collections::queue(), `textDocument/foldingRange` = collections::queue(), `textDocument/documentLink` = collections::queue(), @@ -216,6 +217,8 @@ LanguageServer$set("public", "register_handlers", function() { `documentLink/resolve` = document_link_resolve, `textDocument/documentColor` = text_document_document_color, `textDocument/codeAction` = text_document_code_action, + `textDocument/codeLens` = text_document_code_lens, + `codeLens/resolve` = code_lens_resolve, `textDocument/colorPresentation` = text_document_color_presentation, `textDocument/foldingRange` = text_document_folding_range, `textDocument/references` = text_document_references, diff --git a/README.md b/README.md index ad8da6dc..74df1dd9 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The following editors are supported by installing the corresponding extensions: - [x] [documentSymbolProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol) - [x] [workspaceSymbolProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_symbol) - [x] [codeActionProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction) -- [ ] [codeLensProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeLens) +- [x] [codeLensProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeLens) - [x] [documentFormattingProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting) - [x] [documentRangeFormattingProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rangeFormatting) - [x] [documentOnTypeFormattingProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_onTypeFormatting) diff --git a/tests/testthat/test-codelens.R b/tests/testthat/test-codelens.R new file mode 100644 index 00000000..73d6ba43 --- /dev/null +++ b/tests/testthat/test-codelens.R @@ -0,0 +1,114 @@ +test_that("codeLens returns reference counts", { + temp_dir <- withr::local_tempdir() + temp_file <- file.path(temp_dir, "test.R") + + # Create a main file with a function definition and references + test_code <- c( + "my_function <- function(x) {", + " x + 1", + "}", + "", + "a <- my_function(1)", + "b <- my_function(2)", + "c <- my_function(3)" + ) + writeLines(test_code, temp_file) + + workspace <- languageserver:::Workspace$new(temp_dir) + uri <- languageserver:::path_to_uri(temp_file) + content <- paste(test_code, collapse = "\n") + + # Create document and parse it + document <- languageserver:::Document$new( + uri = uri, + language = "r", + version = 1, + content = test_code + ) + + # Add document to workspace first + workspace$documents$set(uri, document) + + # Then parse the document content to populate parse_data with xml_doc + parse_data <- languageserver:::parse_document(uri, test_code) + workspace$update_parse_data(uri, parse_data) + + # The codeLens should detect the function and its references + lenses <- languageserver:::code_lens_reply(id = 1, uri = uri, + workspace = workspace, document = document) + + # Should return a Response object + expect_s3_class(lenses, "Response") + # Verify it contains reference count for my_function + expect_true(length(lenses$result) > 0) + + # Verify the first lens is a reference count and shows 3 references + first_lens <- lenses$result[[1]] + expect_equal(first_lens$command$title, "3 references") + expect_equal(first_lens$command$command, "") +}) + +test_that("codeLens detects S3 method implementations", { + temp_dir <- withr::local_tempdir() + temp_file <- file.path(temp_dir, "test.R") + + # Create a file with a generic and its methods + test_code <- c( + "print_data <- function(x) UseMethod('print_data')", + "", + "print_data.numeric <- function(x) {", + " cat('Number:', x, '\\n')", + "}", + "", + "print_data.character <- function(x) {", + " cat('Text:', x, '\\n')", + "}" + ) + writeLines(test_code, temp_file) + + workspace <- languageserver:::Workspace$new(temp_dir) + uri <- languageserver:::path_to_uri(temp_file) + + # Create document and parse it + document <- languageserver:::Document$new( + uri = uri, + language = "r", + version = 1, + content = test_code + ) + + # Add document to workspace first + workspace$documents$set(uri, document) + + # Then parse the document content to populate parse_data with xml_doc + parse_data <- languageserver:::parse_document(uri, test_code) + workspace$update_parse_data(uri, parse_data) + + lenses <- languageserver:::code_lens_reply(id = 1, uri = uri, + workspace = workspace, document = document) + + # Should return a Response with method implementations + expect_s3_class(lenses, "Response") + # Verify it contains implementations for print_data + expect_true(length(lenses$result) > 0) +}) + +test_that("codeLens resolve returns the same code lens", { + code_lens_obj <- list( + range = list( + start = list(line = 0, character = 0), + end = list(line = 0, character = 10) + ), + command = list( + title = "3 references", + command = "editor.action.findReferences" + ) + ) + + workspace <- languageserver:::Workspace$new(tempdir()) + result <- languageserver:::code_lens_resolve_reply(id = 1, workspace = workspace, code_lens = code_lens_obj) + + expect_s3_class(result, "Response") + expect_equal(result$result, code_lens_obj) +}) +