mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 00:15:05 +00:00
Add R language support (#574)
This commit is contained in:
1 parent
ff5a502dc7
commit
2ef466eeae
15 files changed
+441
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# Empty init file for R tests
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user