From 2ef466eeae8ab5686f6054966333fddce12a2c74 Mon Sep 17 00:00:00 2001 From: Denis OMeally Date: Sat, 30 Aug 2025 05:37:32 -0700 Subject: [PATCH] Add R language support (#574) --- .github/workflows/pytest.yml | 9 + CHANGELOG.md | 1 + README.md | 1 + pyproject.toml | 1 + .../language_servers/r_language_server.py | 172 ++++++++++++++++++ src/solidlsp/ls.py | 11 ++ src/solidlsp/ls_config.py | 3 + .../resources/repos/r/test_repo/.Rbuildignore | 3 + test/resources/repos/r/test_repo/DESCRIPTION | 13 ++ test/resources/repos/r/test_repo/NAMESPACE | 7 + test/resources/repos/r/test_repo/R/models.R | 48 +++++ test/resources/repos/r/test_repo/R/utils.R | 44 +++++ .../repos/r/test_repo/examples/analysis.R | 30 +++ test/solidlsp/r/__init__.py | 1 + test/solidlsp/r/test_r_basic.py | 97 ++++++++++ 15 files changed, 441 insertions(+) create mode 100644 src/solidlsp/language_servers/r_language_server.py create mode 100644 test/resources/repos/r/test_repo/.Rbuildignore create mode 100644 test/resources/repos/r/test_repo/DESCRIPTION create mode 100644 test/resources/repos/r/test_repo/NAMESPACE create mode 100644 test/resources/repos/r/test_repo/R/models.R create mode 100644 test/resources/repos/r/test_repo/R/utils.R create mode 100644 test/resources/repos/r/test_repo/examples/analysis.R create mode 100644 test/solidlsp/r/__init__.py create mode 100644 test/solidlsp/r/test_r_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1a6552bf..1515cb56 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -124,6 +124,15 @@ jobs: - name: Install Ruby language server shell: bash run: gem install ruby-lsp + - name: Install R + uses: r-lib/actions/setup-r@v2 + with: + r-version: '4.4.2' + use-public-rspm: true + - name: Install R language server + shell: bash + run: | + Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')" - name: Install Zig uses: goto-bus-stop/setup-zig@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 86147c22..ee0747ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * Language support: + * **Add support for R** via the R languageserver package with LSP integration, performance optimizations, and fallback symbol extraction * **Add support for Zig** via ZLS (cross-file references may not fully work on Windows) * **Add support for Lua** via lua-language-server * **Add support for Nix** requires nixd installation (Windows not supported) diff --git a/README.md b/README.md index ca2ee906..4eb933a1 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ With Serena, we provide direct, out-of-the-box support for: * TypeScript/Javascript * PHP (uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features) * Go (requires installation of gopls) + * R (requires installation of the `languageserver` R package) * Rust (requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain) * C/C++ (you may experience issues with finding references, we are working on it) * Zig (requires installation of ZLS - Zig Language Server) diff --git a/pyproject.toml b/pyproject.toml index a62a2cf7..69559c22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -263,6 +263,7 @@ markers = [ "terraform: language server running for Terraform", "swift: language server running for Swift", "bash: language server running for Bash", + "r: language server running for R", "snapshot: snapshot tests for symbolic editing operations", "ruby: language server running for Ruby (uses ruby-lsp)", "zig: language server running for Zig", diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py new file mode 100644 index 00000000..91aa6935 --- /dev/null +++ b/src/solidlsp/language_servers/r_language_server.py @@ -0,0 +1,172 @@ +import logging +import os +import pathlib +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class RLanguageServer(SolidLanguageServer): + """R Language Server implementation using the languageserver R package.""" + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5.0 # R language server needs extra time for workspace indexing in CI environments + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For R projects, ignore common directories + return super().is_ignored_dirname(dirname) or dirname in [ + "renv", # R environment management + "packrat", # Legacy R package management + ".Rproj.user", # RStudio project files + "vignettes", # Package vignettes (often large) + ] + + @staticmethod + def _check_r_installation(): + """Check if R and languageserver are available.""" + try: + # Check R installation + result = subprocess.run(["R", "--version"], capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError("R is not installed or not in PATH") + + # Check languageserver package + result = subprocess.run( + ["R", "--vanilla", "--quiet", "--slave", "-e", "if (!require('languageserver', quietly=TRUE)) quit(status=1)"], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError( + "R languageserver package is not installed.\nInstall it with: R -e \"install.packages('languageserver')\"" + ) + + except FileNotFoundError: + raise RuntimeError("R is not installed. Please install R from https://www.r-project.org/") + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + # Check R installation + self._check_r_installation() + + # R command to start language server + # Use --vanilla for minimal startup and --quiet to suppress all output except LSP + # Set specific options to improve parsing stability + r_cmd = 'R --vanilla --quiet --slave -e "options(languageserver.debug_mode = FALSE); languageserver::run()"' + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), + "r", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """Initialize params for R Language Server.""" + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params + + def _start_server(self): + """Start R Language Server process.""" + + def window_log_message(msg): + self.logger.log(f"R LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + def register_capability_handler(params): + return + + # Register LSP message handlers + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting R Language Server process", logging.INFO) + self.server.start() + + initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log( + "Sending initialize request to R Language Server", + logging.INFO, + ) + + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + capabilities = init_response.get("capabilities", {}) + assert "textDocumentSync" in capabilities + if "completionProvider" in capabilities: + self.logger.log("R LSP completion provider available", logging.INFO) + if "definitionProvider" in capabilities: + self.logger.log("R LSP definition provider available", logging.INFO) + + self.server.notify.initialized({}) + self.completions_available.set() + + # R Language Server is ready after initialization + self.server_ready.set() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 1a2b65ca..b2c74b3c 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -218,6 +218,11 @@ class SolidLanguageServer(ABC): ls = Intelephense(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) + elif config.code_language == Language.R: + from solidlsp.language_servers.r_language_server import RLanguageServer + + ls = RLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) + elif config.code_language == Language.CLOJURE: from solidlsp.language_servers.clojure_lsp import ClojureLSP @@ -643,6 +648,12 @@ class SolidLanguageServer(ABC): ) raise SolidLSPException("Language Server not started") + if not self._has_waited_for_cross_file_references: + # Some LS require waiting for a while before they can return cross-file definitions. + # This is a workaround for such LS that don't have a reliable "finished initializing" signal. + sleep(self._get_wait_time_for_cross_file_referencing()) + self._has_waited_for_cross_file_references = True + with self.open_file(relative_file_path): # sending request to the language server and waiting for response definition_params = cast( diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index bfea01a4..fb266d67 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -39,6 +39,7 @@ class Language(str, Enum): DART = "dart" CPP = "cpp" PHP = "php" + R = "r" CLOJURE = "clojure" ELIXIR = "elixir" TERRAFORM = "terraform" @@ -109,6 +110,8 @@ class Language(str, Enum): return FilenameMatcher("*.dart") case self.PHP: return FilenameMatcher("*.php") + case self.R: + return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw") case self.CLOJURE: return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn case self.ELIXIR: diff --git a/test/resources/repos/r/test_repo/.Rbuildignore b/test/resources/repos/r/test_repo/.Rbuildignore new file mode 100644 index 00000000..168b5139 --- /dev/null +++ b/test/resources/repos/r/test_repo/.Rbuildignore @@ -0,0 +1,3 @@ +^.*\.Rproj$ +^\.Rproj\.user$ +^\.serena$ \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/DESCRIPTION b/test/resources/repos/r/test_repo/DESCRIPTION new file mode 100644 index 00000000..d42148cf --- /dev/null +++ b/test/resources/repos/r/test_repo/DESCRIPTION @@ -0,0 +1,13 @@ +Package: testpackage +Type: Package +Title: Test Package for R Language Server +Version: 1.0.0 +Author: Serena Test +Maintainer: Serena Test +Description: A minimal test package for testing R language server functionality. + This package contains sample R functions for testing symbol extraction, + completion, and other language server features. +License: MIT + file LICENSE +Encoding: UTF-8 +LazyData: true +RoxygenNote: 7.2.0 \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/NAMESPACE b/test/resources/repos/r/test_repo/NAMESPACE new file mode 100644 index 00000000..857bf83f --- /dev/null +++ b/test/resources/repos/r/test_repo/NAMESPACE @@ -0,0 +1,7 @@ +# Generated by roxygen2: do not edit by hand + +export(calculate_mean) +export(create_data_frame) +export(fit_linear_model) +export(plot_data) +export(process_data) \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/R/models.R b/test/resources/repos/r/test_repo/R/models.R new file mode 100644 index 00000000..b130e8fb --- /dev/null +++ b/test/resources/repos/r/test_repo/R/models.R @@ -0,0 +1,48 @@ +#' Fit a linear model +#' +#' @param formula A formula for the model +#' @param data A data frame containing the variables +#' @return A fitted lm object +#' @export +fit_linear_model <- function(formula, data) { + if (missing(formula) || missing(data)) { + stop("Both formula and data are required") + } + + model <- lm(formula, data = data) + + # Add some custom attributes + attr(model, "created_by") <- "fit_linear_model" + attr(model, "creation_time") <- Sys.time() + + return(model) +} + +#' Plot data using ggplot2-style syntax +#' +#' @param data A data frame +#' @param x_var Column name for x-axis +#' @param y_var Column name for y-axis +#' @return A plot object +#' @export +plot_data <- function(data, x_var, y_var) { + if (!is.data.frame(data)) { + stop("data must be a data frame") + } + + if (!(x_var %in% names(data))) { + stop(paste("Column", x_var, "not found in data")) + } + + if (!(y_var %in% names(data))) { + stop(paste("Column", y_var, "not found in data")) + } + + # Create a simple base R plot + plot(data[[x_var]], data[[y_var]], + xlab = x_var, ylab = y_var, + main = paste(y_var, "vs", x_var)) + + # Add a trend line + abline(lm(data[[y_var]] ~ data[[x_var]]), col = "red") +} \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/R/utils.R b/test/resources/repos/r/test_repo/R/utils.R new file mode 100644 index 00000000..d516f150 --- /dev/null +++ b/test/resources/repos/r/test_repo/R/utils.R @@ -0,0 +1,44 @@ +#' Calculate mean of numeric vector +#' +#' @param x A numeric vector +#' @return The mean of the vector +#' @export +calculate_mean <- function(x) { + if (!is.numeric(x)) { + stop("Input must be numeric") + } + mean(x, na.rm = TRUE) +} + +#' Process data by removing missing values +#' +#' @param data A data frame +#' @return A cleaned data frame +#' @export +process_data <- function(data) { + if (!is.data.frame(data)) { + stop("Input must be a data frame") + } + + # Remove rows with any missing values + clean_data <- na.omit(data) + + # Add a processed flag + clean_data$processed <- TRUE + + return(clean_data) +} + +#' Create a sample data frame +#' +#' @param n Number of rows to create +#' @return A data frame with sample data +#' @export +create_data_frame <- function(n = 100) { + data.frame( + id = 1:n, + value = rnorm(n), + category = sample(c("A", "B", "C"), n, replace = TRUE), + stringsAsFactors = FALSE + ) +} \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/examples/analysis.R b/test/resources/repos/r/test_repo/examples/analysis.R new file mode 100644 index 00000000..508c451e --- /dev/null +++ b/test/resources/repos/r/test_repo/examples/analysis.R @@ -0,0 +1,30 @@ +# Example R script demonstrating package usage + +# Load required libraries +library(testpackage) + +# Create sample data +sample_data <- create_data_frame(n = 50) + +# Process the data +clean_data <- process_data(sample_data) + +# Calculate some statistics +mean_value <- calculate_mean(clean_data$value) +cat("Mean value:", mean_value, "\n") + +# Fit a simple model +model <- fit_linear_model(value ~ id, data = clean_data) +summary(model) + +# Create a plot +plot_data(clean_data, "id", "value") + +# Additional analysis function (not exported) +analyze_categories <- function(data) { + table(data$category) +} + +# Run the analysis +category_summary <- analyze_categories(clean_data) +print(category_summary) \ No newline at end of file diff --git a/test/solidlsp/r/__init__.py b/test/solidlsp/r/__init__.py new file mode 100644 index 00000000..3348d2e3 --- /dev/null +++ b/test/solidlsp/r/__init__.py @@ -0,0 +1 @@ +# Empty init file for R tests diff --git a/test/solidlsp/r/test_r_basic.py b/test/solidlsp/r/test_r_basic.py new file mode 100644 index 00000000..56ff345a --- /dev/null +++ b/test/solidlsp/r/test_r_basic.py @@ -0,0 +1,97 @@ +""" +Basic tests for R Language Server integration +""" + +import os +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.r +class TestRLanguageServer: + """Test basic functionality of the R language server.""" + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.R], indirect=True) + def test_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path): + """Test that the R language server initializes properly.""" + assert language_server is not None + assert language_server.language_id == "r" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_symbol_retrieval(self, language_server: SolidLanguageServer): + """Test R document symbol extraction.""" + all_symbols, root_symbols = language_server.request_document_symbols(os.path.join("R", "utils.R")) + + # Should find the three exported functions + function_symbols = [s for s in all_symbols if s.get("kind") == 12] # Function kind + assert len(function_symbols) >= 3 + + # Check that we found the expected functions + function_names = {s.get("name") for s in function_symbols} + expected_functions = {"calculate_mean", "process_data", "create_data_frame"} + assert expected_functions.issubset(function_names), f"Expected functions {expected_functions} but found {function_names}" + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer): + """Test finding function definitions across files.""" + analysis_file = os.path.join("examples", "analysis.R") + + # In analysis.R line 7: create_data_frame(n = 50) + # The function create_data_frame is defined in R/utils.R + # Find definition of create_data_frame function call (0-indexed: line 6) + definition_location_list = language_server.request_definition(analysis_file, 6, 17) # cursor on 'create_data_frame' + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("utils.R") + # Definition should be around line 37 (0-indexed: 36) where create_data_frame is defined + assert definition_location["range"]["start"]["line"] >= 35 + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer): + """Test finding function references across files.""" + analysis_file = os.path.join("examples", "analysis.R") + + # Test from usage side: find references to calculate_mean from its usage in analysis.R + # In analysis.R line 13: calculate_mean(clean_data$value) + # calculate_mean function call is at line 13 (0-indexed: line 12) + references = language_server.request_references(analysis_file, 12, 15) # cursor on 'calculate_mean' + + assert references, f"Expected non-empty references for calculate_mean but got {references=}" + + # Must find the definition in utils.R (cross-file reference) + reference_files = [ref["uri"] for ref in references] + assert any(uri.endswith("utils.R") for uri in reference_files), "Cross-file reference to definition in utils.R not found" + + # Verify we actually found the right location in utils.R + utils_refs = [ref for ref in references if ref["uri"].endswith("utils.R")] + assert len(utils_refs) >= 1, "Should find at least one reference in utils.R" + utils_ref = utils_refs[0] + # Should be around line 6 where calculate_mean is defined (0-indexed: line 5) + assert ( + utils_ref["range"]["start"]["line"] == 5 + ), f"Expected reference at line 5 in utils.R, got line {utils_ref['range']['start']['line']}" + + def test_file_matching(self): + """Test that R files are properly matched.""" + from solidlsp.ls_config import Language + + matcher = Language.R.get_source_fn_matcher() + + assert matcher.is_relevant_filename("script.R") + assert matcher.is_relevant_filename("analysis.r") + assert not matcher.is_relevant_filename("script.py") + assert not matcher.is_relevant_filename("README.md") + + def test_r_language_enum(self): + """Test R language enum value.""" + assert Language.R == "r" + assert str(Language.R) == "r"