feat(gopls): support gopls_settings via ls_specific_settings

- Forward ls_specific_settings.go.gopls_settings to gopls as LSP initializationOptions (JSON-validated)
- Add cache context fingerprinting so persisted symbol caches are invalidated when Go build context changes (e.g. buildFlags, GOFLAGS, GOOS, GOARCH, CGO_ENABLED)
- Add build-tag fixtures + tests covering tag-dependent symbols and cache invalidation
- Document ls_specific_settings + Go example in configuration docs
This commit is contained in:
Roman Nikulenkov committed 2026-01-05 15:58:43 +03:00
1 parent 962fb486b6
commit 71d23277da
8 files changed
+344 -15

No files matched your search

+41
View File
@@ -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.
+80 -3
View File
@@ -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,86 @@ 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:
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': {...}, ...}"
)
# Validate JSON-serializability early: initializationOptions is sent over JSON-RPC.
import json
try:
json.dumps(gopls_settings, 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
# 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)
# 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:
if not isinstance(gopls_settings_raw, dict):
raise TypeError(
f"gopls_settings must be a dict, got {type(gopls_settings_raw).__name__}. "
"Expected structure: {'buildFlags': ['-tags=foo'], 'env': {...}, ...}"
)
# Treat an explicitly empty dict the same as not providing settings at all.
gopls_settings = 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
try:
canonical_json = json.dumps(fingerprint_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
return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16]
def _start_server(self) -> None:
"""Start gopls server process"""
+31 -4
View File
@@ -1699,6 +1699,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
@@ -1717,8 +1740,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
@@ -1774,7 +1801,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(
@@ -1788,7 +1815,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
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module test_repo
go 1.21
+154 -3
View File
@@ -1,4 +1,5 @@
import os
from pathlib import Path
import pytest
@@ -28,6 +29,156 @@ 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")
# 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 ls_default_again._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk"
assert ls_default_again._document_symbols_cache, "Expected document-symbol cache to load from disk"
assert not ls_default_again._raw_document_symbols_cache_is_modified
assert not ls_default_again._document_symbols_cache_is_modified
_ = 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 ls_foo._raw_document_symbols_cache == {}
assert ls_foo._document_symbols_cache == {}
_ = ls_foo.request_document_symbols(main_go)
# A cache miss should repopulate and mark caches modified.
assert ls_foo._raw_document_symbols_cache_is_modified
assert ls_foo._document_symbols_cache_is_modified