mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 12:15:10 +00:00
Merge pull request #902 from n-r-w/golang-ls-specific-settings
feat(gopls): support gopls_settings via ls_specific_settings
This commit is contained in:
9 files changed
+353
-16
No files matched your search
@@ -0,0 +1 @@
|
||||
MUST use the project-specific instructions from the `CLAUDE.md` file located in the project root.
|
||||
@@ -134,6 +134,47 @@ For advanced users, Serena's configuration can be further customized.
|
||||
The Serena user data directory (where configuration, language server files, logs, etc. are stored) defaults to `~/.serena`.
|
||||
You can change this location by setting the `SERENA_HOME` environment variable to your desired path.
|
||||
|
||||
### Language server specific settings (`ls_specific_settings`)
|
||||
|
||||
`ls_specific_settings` lets you pass per-language, language-server-specific configuration.
|
||||
|
||||
Structure:
|
||||
|
||||
```yaml
|
||||
ls_specific_settings:
|
||||
<language>:
|
||||
# language-server-specific keys
|
||||
```
|
||||
|
||||
Where to put it:
|
||||
- Global default: `~/.serena/serena_config.yml`
|
||||
- Per-project override: `<project>/.serena/project.yml` (takes precedence over the global config for the same keys)
|
||||
- There is currently no `serena-mcp-server` CLI option to set `ls_specific_settings`; use the config files above.
|
||||
|
||||
#### Go (`gopls`)
|
||||
|
||||
Serena forwards `ls_specific_settings.go.gopls_settings` to `gopls` as LSP `initializationOptions` when the Go language server is started.
|
||||
|
||||
Example: enable build tags and set a build environment:
|
||||
|
||||
```yaml
|
||||
ls_specific_settings:
|
||||
go:
|
||||
gopls_settings:
|
||||
buildFlags:
|
||||
- "-tags=foo"
|
||||
env:
|
||||
GOOS: "linux"
|
||||
GOARCH: "amd64"
|
||||
CGO_ENABLED: "0"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- To enable multiple tags, use `"-tags=foo,bar"`.
|
||||
- `gopls_settings.env` values are strings.
|
||||
- `GOFLAGS` (from the environment you start Serena in) may also affect the Go build context. Prefer `buildFlags` for tags.
|
||||
- Build context changes are only picked up when `gopls` starts. After changing `gopls_settings` (or relevant env vars like `GOFLAGS`), restart the Serena process (or server) that hosts the Go language server, or use your client's "Restart language server" action if it causes `gopls` to restart.
|
||||
|
||||
### Custom Prompts
|
||||
|
||||
All of Serena's prompts can be fully customized.
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
from overrides import override
|
||||
|
||||
@@ -98,13 +98,12 @@ class Gopls(SolidLanguageServer):
|
||||
self.server_ready = threading.Event()
|
||||
self.request_id = 0
|
||||
|
||||
@staticmethod
|
||||
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
|
||||
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
|
||||
"""
|
||||
Returns the initialize params for the Go Language Server.
|
||||
"""
|
||||
root_uri = pathlib.Path(repository_absolute_path).as_uri()
|
||||
initialize_params = {
|
||||
initialize_params: dict = {
|
||||
"locale": "en",
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
@@ -128,8 +127,83 @@ class Gopls(SolidLanguageServer):
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Apply gopls-specific settings via initializationOptions
|
||||
# Serena applies gopls settings at initialization time via initializationOptions
|
||||
# (Access settings directly to avoid extra INFO logging from CustomLSSettings.get.)
|
||||
gopls_settings = self._custom_settings.settings.get("gopls_settings")
|
||||
if gopls_settings:
|
||||
gopls_settings = self._validate_gopls_settings_dict(gopls_settings)
|
||||
|
||||
# Validate JSON-serializability early: initializationOptions is sent over JSON-RPC.
|
||||
import json
|
||||
|
||||
self._canonical_json_or_raise(json, gopls_settings)
|
||||
|
||||
# Log keys only (and at DEBUG) to avoid leaking sensitive values and to reduce startup noise.
|
||||
log.debug("Applying gopls settings via initializationOptions: keys=%s", list(gopls_settings.keys()))
|
||||
initialize_params["initializationOptions"] = gopls_settings
|
||||
|
||||
return cast(InitializeParams, initialize_params)
|
||||
|
||||
def _validate_gopls_settings_dict(self, gopls_settings: object) -> dict:
|
||||
if not isinstance(gopls_settings, dict):
|
||||
raise TypeError(
|
||||
f"gopls_settings must be a dict, got {type(gopls_settings).__name__}. "
|
||||
"Expected structure: {'buildFlags': ['-tags=foo'], 'env': {...}, ...}"
|
||||
)
|
||||
|
||||
return gopls_settings
|
||||
|
||||
def _canonical_json_or_raise(self, json_module: Any, data: object) -> str:
|
||||
try:
|
||||
return json_module.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TypeError(
|
||||
"gopls_settings must be JSON-serializable (json.dumps). Use JSON-compatible values (dict/list/str/int/float/bool/null) and prefer string keys."
|
||||
) from exc
|
||||
|
||||
# Environment variables that influence Go build context and affect cached symbols.
|
||||
_CACHE_CONTEXT_ENV_KEYS = ("GOFLAGS", "GOOS", "GOARCH", "CGO_ENABLED")
|
||||
|
||||
@override
|
||||
def _cache_context_fingerprint(self) -> str | None:
|
||||
"""
|
||||
Compute a deterministic fingerprint of the Go build context.
|
||||
|
||||
The fingerprint includes gopls_settings and selected env vars that affect symbol discovery.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
gopls_settings_raw = self._custom_settings.settings.get("gopls_settings")
|
||||
|
||||
gopls_settings: dict | None
|
||||
if gopls_settings_raw is None:
|
||||
gopls_settings = None
|
||||
else:
|
||||
# Treat an explicitly empty dict the same as not providing settings at all.
|
||||
gopls_settings = self._validate_gopls_settings_dict(gopls_settings_raw) or None
|
||||
|
||||
# Only include env vars that are set to a non-empty value.
|
||||
env_subset: dict[str, str] = {}
|
||||
for key in self._CACHE_CONTEXT_ENV_KEYS:
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
env_subset[key] = value
|
||||
|
||||
# Return None only when BOTH settings and env subset are effectively empty.
|
||||
if gopls_settings is None and not env_subset:
|
||||
return None
|
||||
|
||||
fingerprint_data: dict[str, object] = {"env": env_subset}
|
||||
if gopls_settings is not None:
|
||||
fingerprint_data["gopls_settings"] = gopls_settings
|
||||
|
||||
canonical_json = self._canonical_json_or_raise(json, fingerprint_data)
|
||||
|
||||
return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _start_server(self) -> None:
|
||||
"""Start gopls server process"""
|
||||
|
||||
|
||||
+31
-4
@@ -1745,6 +1745,29 @@ class SolidLanguageServer(ABC):
|
||||
|
||||
return defining_symbol
|
||||
|
||||
def _cache_context_fingerprint(self) -> Hashable | None:
|
||||
"""
|
||||
Return a fingerprint of any language-server-specific context that affects cached results.
|
||||
|
||||
Subclasses may override to provide a deterministic value that changes when cached results
|
||||
would be invalidated (e.g., build flags, environment variables).
|
||||
|
||||
The value must be hashable and safe for inclusion in cache version tuples.
|
||||
Returns None if no context-specific fingerprint is needed.
|
||||
"""
|
||||
return None
|
||||
|
||||
def _document_symbols_cache_version(self) -> Hashable:
|
||||
"""
|
||||
Return the version for the document symbols cache.
|
||||
|
||||
Incorporates cache context fingerprint if provided by the language server.
|
||||
"""
|
||||
fingerprint = self._cache_context_fingerprint()
|
||||
if fingerprint is not None:
|
||||
return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint)
|
||||
return self.DOCUMENT_SYMBOL_CACHE_VERSION
|
||||
|
||||
def _save_raw_document_symbols_cache(self) -> None:
|
||||
cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
|
||||
|
||||
@@ -1763,8 +1786,12 @@ class SolidLanguageServer(ABC):
|
||||
e,
|
||||
)
|
||||
|
||||
def _raw_document_symbols_cache_version(self) -> tuple[int, Hashable]:
|
||||
return (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version)
|
||||
def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]:
|
||||
base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version)
|
||||
fingerprint = self._cache_context_fingerprint()
|
||||
if fingerprint is not None:
|
||||
return (*base_version, fingerprint)
|
||||
return base_version
|
||||
|
||||
def _load_raw_document_symbols_cache(self) -> None:
|
||||
cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
|
||||
@@ -1820,7 +1847,7 @@ class SolidLanguageServer(ABC):
|
||||
|
||||
log.info("Saving updated document symbols cache to %s", cache_file)
|
||||
try:
|
||||
save_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION, self._document_symbols_cache)
|
||||
save_cache(str(cache_file), self._document_symbols_cache_version(), self._document_symbols_cache)
|
||||
self._document_symbols_cache_is_modified = False
|
||||
except Exception as e:
|
||||
log.error(
|
||||
@@ -1834,7 +1861,7 @@ class SolidLanguageServer(ABC):
|
||||
if cache_file.exists():
|
||||
log.info("Loading document symbols cache from %s", cache_file)
|
||||
try:
|
||||
saved_cache = load_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION)
|
||||
saved_cache = load_cache(str(cache_file), self._document_symbols_cache_version())
|
||||
if saved_cache is not None:
|
||||
self._document_symbols_cache = saved_cache
|
||||
log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.")
|
||||
|
||||
+19
-5
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from blib2to3.pgen2.parse import contextmanager
|
||||
from sensai.util.logging import configure
|
||||
|
||||
from serena.config.serena_config import SerenaPaths
|
||||
@@ -38,7 +39,12 @@ def get_repo_path(language: Language) -> Path:
|
||||
|
||||
|
||||
def _create_ls(
|
||||
language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False
|
||||
language: Language,
|
||||
repo_path: str | None = None,
|
||||
ignored_paths: list[str] | None = None,
|
||||
trace_lsp_communication: bool = False,
|
||||
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
|
||||
solidlsp_dir: Path | None = None,
|
||||
) -> SolidLanguageServer:
|
||||
ignored_paths = ignored_paths or []
|
||||
if repo_path is None:
|
||||
@@ -47,20 +53,28 @@ def _create_ls(
|
||||
for spec in gitignore_parser.get_ignore_specs():
|
||||
ignored_paths.extend(spec.patterns)
|
||||
config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication)
|
||||
effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir
|
||||
return SolidLanguageServer.create(
|
||||
config,
|
||||
repo_path,
|
||||
solidlsp_settings=SolidLSPSettings(
|
||||
solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME
|
||||
solidlsp_dir=effective_solidlsp_dir,
|
||||
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
|
||||
ls_specific_settings=ls_specific_settings or {},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def start_ls_context(
|
||||
language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False
|
||||
language: Language,
|
||||
repo_path: str | None = None,
|
||||
ignored_paths: list[str] | None = None,
|
||||
trace_lsp_communication: bool = False,
|
||||
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
|
||||
solidlsp_dir: Path | None = None,
|
||||
) -> Iterator[SolidLanguageServer]:
|
||||
ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication)
|
||||
ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, solidlsp_dir)
|
||||
log.info(f"Starting language server for {language} {repo_path}")
|
||||
ls.start()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build foo
|
||||
// +build foo
|
||||
|
||||
package buildtags
|
||||
|
||||
type XFoo struct {
|
||||
Value int
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !foo
|
||||
// +build !foo
|
||||
|
||||
package buildtags
|
||||
|
||||
type XNotFoo struct {
|
||||
Value int
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module test_repo
|
||||
|
||||
go 1.21
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -28,6 +29,166 @@ class TestGoLanguageServer:
|
||||
assert helper_symbol is not None, "Could not find 'Helper' function symbol in main.go"
|
||||
sel_start = helper_symbol["selectionRange"]["start"]
|
||||
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
|
||||
assert any(
|
||||
"main.go" in ref.get("relativePath", "") for ref in refs
|
||||
), "main.go should reference Helper (tried all positions in selectionRange)"
|
||||
assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go"
|
||||
|
||||
|
||||
def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list:
|
||||
"""Filter workspace symbols to exact name matches in the test repo."""
|
||||
if symbols is None:
|
||||
return []
|
||||
return [s for s in symbols if s.get("name") == target_name and repo_name in s.get("location", {}).get("uri", "")]
|
||||
|
||||
|
||||
@pytest.mark.go
|
||||
class TestGoBuildTags:
|
||||
"""Tests for Go build tag/constraint support."""
|
||||
|
||||
def _copy_go_fixture(self, tmp_path: Path) -> Path:
|
||||
"""Copy Go fixture repo into tmp_path."""
|
||||
import shutil
|
||||
|
||||
from test.conftest import get_repo_path
|
||||
|
||||
fixture_path = get_repo_path(Language.GO)
|
||||
target_path = tmp_path / "test_repo"
|
||||
|
||||
shutil.copytree(fixture_path, target_path)
|
||||
return target_path
|
||||
|
||||
def test_default_context_contains_xnotfoo(self, tmp_path: Path) -> None:
|
||||
"""Default build context should contain XNotFoo and not XFoo."""
|
||||
from test.conftest import start_ls_context
|
||||
|
||||
repo_path = self._copy_go_fixture(tmp_path)
|
||||
|
||||
with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls:
|
||||
xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo")
|
||||
xfoo_symbols = ls.request_workspace_symbol("XFoo")
|
||||
|
||||
xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo")
|
||||
xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo")
|
||||
|
||||
assert len(xnotfoo_matches) > 0, "Default context should contain XNotFoo"
|
||||
assert len(xfoo_matches) == 0, "Default context should NOT contain XFoo"
|
||||
|
||||
def test_foo_context_contains_xfoo(self, tmp_path: Path) -> None:
|
||||
"""Build context with -tags=foo should contain XFoo and not XNotFoo."""
|
||||
from test.conftest import start_ls_context
|
||||
|
||||
repo_path = self._copy_go_fixture(tmp_path)
|
||||
|
||||
ls_settings = {
|
||||
Language.GO: {
|
||||
"gopls_settings": {
|
||||
"buildFlags": ["-tags=foo"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with start_ls_context(Language.GO, repo_path=str(repo_path), ls_specific_settings=ls_settings, solidlsp_dir=tmp_path) as ls:
|
||||
xfoo_symbols = ls.request_workspace_symbol("XFoo")
|
||||
xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo")
|
||||
|
||||
xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo")
|
||||
xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo")
|
||||
|
||||
assert len(xfoo_matches) > 0, "Foo context should contain XFoo"
|
||||
assert len(xnotfoo_matches) == 0, "Foo context should NOT contain XNotFoo"
|
||||
|
||||
def test_disk_cache_is_invalidated_on_build_context_switch(self, tmp_path: Path) -> None:
|
||||
"""Go build context switches must not reuse persisted SolidLSP document-symbol caches."""
|
||||
import pickle
|
||||
|
||||
from test.conftest import start_ls_context
|
||||
|
||||
repo_path = self._copy_go_fixture(tmp_path)
|
||||
|
||||
ls_settings_foo = {
|
||||
Language.GO: {
|
||||
"gopls_settings": {
|
||||
"buildFlags": ["-tags=foo"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
main_go = os.path.join("main.go")
|
||||
|
||||
def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None:
|
||||
# White-box assertions: SolidLanguageServer currently has no public API to verify that
|
||||
# caches were loaded from disk vs created lazily on first request.
|
||||
assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk"
|
||||
assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk"
|
||||
assert not ls._raw_document_symbols_cache_is_modified
|
||||
assert not ls._document_symbols_cache_is_modified
|
||||
|
||||
def _assert_caches_empty(ls: SolidLanguageServer) -> None:
|
||||
assert ls._raw_document_symbols_cache == {}
|
||||
assert ls._document_symbols_cache == {}
|
||||
|
||||
def _assert_caches_modified(ls: SolidLanguageServer) -> None:
|
||||
assert ls._raw_document_symbols_cache_is_modified
|
||||
assert ls._document_symbols_cache_is_modified
|
||||
|
||||
# Run 1 (default context): populate caches and persist them to disk.
|
||||
with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default:
|
||||
_ = ls_default.request_document_symbols(main_go)
|
||||
|
||||
default_raw_cache_version = ls_default._raw_document_symbols_cache_version()
|
||||
default_doc_cache_version = ls_default._document_symbols_cache_version()
|
||||
|
||||
ls_default.save_cache()
|
||||
cache_dir = ls_default.cache_dir
|
||||
|
||||
cache_files = [p for p in cache_dir.rglob("*") if p.is_file()]
|
||||
assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}"
|
||||
|
||||
versioned_cache_files: list[tuple[Path, object]] = []
|
||||
for p in cache_files:
|
||||
try:
|
||||
with p.open("rb") as f:
|
||||
data = pickle.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(data, dict) and "__cache_version" in data:
|
||||
versioned_cache_files.append((p, data["__cache_version"]))
|
||||
|
||||
assert versioned_cache_files, f"Expected at least one SolidLSP cache file with a __cache_version under {cache_dir}"
|
||||
saved_versions = {v for _, v in versioned_cache_files}
|
||||
assert (
|
||||
default_raw_cache_version in saved_versions or default_doc_cache_version in saved_versions
|
||||
), "Expected at least one persisted cache to match the default-context cache version"
|
||||
|
||||
# Run 2 (default context again): prove that persisted caches are actually loaded and used.
|
||||
with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again:
|
||||
assert ls_default_again.cache_dir == cache_dir
|
||||
|
||||
_assert_caches_loaded_and_clean(ls_default_again)
|
||||
|
||||
_ = ls_default_again.request_document_symbols(main_go)
|
||||
|
||||
# A cache hit should not mark caches as modified.
|
||||
assert not ls_default_again._raw_document_symbols_cache_is_modified
|
||||
assert not ls_default_again._document_symbols_cache_is_modified
|
||||
|
||||
# Run 3 (foo context): the same on-disk cache directory exists, but MUST be treated as stale.
|
||||
with start_ls_context(
|
||||
Language.GO,
|
||||
repo_path=str(repo_path),
|
||||
ls_specific_settings=ls_settings_foo,
|
||||
solidlsp_dir=tmp_path,
|
||||
) as ls_foo:
|
||||
assert ls_foo.cache_dir == cache_dir
|
||||
|
||||
foo_raw_cache_version = ls_foo._raw_document_symbols_cache_version()
|
||||
foo_doc_cache_version = ls_foo._document_symbols_cache_version()
|
||||
|
||||
assert foo_raw_cache_version != default_raw_cache_version
|
||||
assert foo_doc_cache_version != default_doc_cache_version
|
||||
|
||||
# Different build context => persisted caches must not be loaded.
|
||||
_assert_caches_empty(ls_foo)
|
||||
|
||||
_ = ls_foo.request_document_symbols(main_go)
|
||||
|
||||
# A cache miss should repopulate and mark caches modified.
|
||||
_assert_caches_modified(ls_foo)
|
||||
Reference in new issue
Block a user