Add R language support (#574)

This commit is contained in:
Denis OMeally authored and GitHub committed 2025-08-30 14:37:32 +02:00
1 parent ff5a502dc7
commit 2ef466eeae
15 files changed
+441

No files matched your search

+9
View File
@@ -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:
+1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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",
@@ -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()
+11
View File
@@ -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(
+3
View File
@@ -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:
@@ -0,0 +1,3 @@
^.*\.Rproj$
^\.Rproj\.user$
^\.serena$
@@ -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 <test@example.com>
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
@@ -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)
@@ -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")
}
@@ -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
)
}
@@ -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)
+1
View File
@@ -0,0 +1 @@
# Empty init file for R tests
+97
View File
@@ -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"