mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 00:15:05 +00:00
Merge remote-tracking branch 'origin/main' into ls-dependency-provider
This commit is contained in:
commit
deb416353b
19 files changed
+521
-56
No files matched your search
@@ -0,0 +1 @@
|
||||
MUST use the project-specific instructions from the `CLAUDE.md` file located in the project root.
|
||||
@@ -91,7 +91,7 @@ see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena
|
||||
As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/)
|
||||
leverages the powerful code analysis capabilities of JetBrains IDEs.
|
||||
The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs,
|
||||
including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, Rider, and others.
|
||||
including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, and others.
|
||||
|
||||
When using the plugin, Serena connects to an instance of your JetBrains IDE via the plugin. For users who already
|
||||
work in a JetBrains IDE, this means Serena seamlessly integrates with the IDE instance you typically have open anyway,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,6 +12,9 @@ Serena comes with built-in tools for monitoring and managing the current session
|
||||
By default, it will be accessible at `http://localhost:24282/dashboard/index.html`,
|
||||
but a higher port may be used if the default port is unavailable/multiple instances are running.
|
||||
|
||||
**We recommend always enabling the dashboard**. If you don't want the browser to open automatically,
|
||||
you can disable it while still keeping the dashboard running in the background (see below).
|
||||
|
||||
* the **GUI tool** (disabled by default)
|
||||
|
||||
The GUI tool is a native application window which displays logs.
|
||||
@@ -26,7 +29,8 @@ For the dashboard, this can be disabled if desired (see below).
|
||||
## Disabling Automatic Browser Opening
|
||||
|
||||
If you prefer not to have the dashboard open automatically (e.g., to avoid focus stealing), you can disable it
|
||||
by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml`.
|
||||
by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml` or by passing `--open-web-dashboard False`
|
||||
to `start-mcp-server` CLI command.
|
||||
|
||||
When automatic opening is disabled, you can still access the dashboard by:
|
||||
* asking the LLM to "open the Serena dashboard", which will open the dashboard in your default browser
|
||||
|
||||
+83
-24
@@ -36,6 +36,8 @@ from solidlsp.util.subprocess_util import subprocess_kwargs
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_MAX_CONTENT_WIDTH = 100
|
||||
|
||||
|
||||
def find_project_root(root: str | Path | None = None) -> str:
|
||||
"""Find project root by walking up from CWD.
|
||||
@@ -138,7 +140,7 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
super().__init__(name="serena", help="Serena CLI commands. You can run `<command> --help` for more info on each command.")
|
||||
|
||||
@staticmethod
|
||||
@click.command("start-mcp-server", help="Starts the Serena MCP server.")
|
||||
@click.command("start-mcp-server", help="Starts the Serena MCP server.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.")
|
||||
@click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.")
|
||||
@click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="")
|
||||
@@ -177,8 +179,29 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
@click.option(
|
||||
"--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)."
|
||||
)
|
||||
@click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.")
|
||||
@click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.")
|
||||
@click.option(
|
||||
"--enable-web-dashboard",
|
||||
type=bool,
|
||||
is_flag=False,
|
||||
default=None,
|
||||
help="Enable the web dashboard (overriding the setting in Serena's config). "
|
||||
"It is recommended to always enable the dashboard. If you don't want the browser to open on startup, set open-web-dashboard to False. "
|
||||
"For more information, see\nhttps://oraios.github.io/serena/02-usage/060_dashboard.html",
|
||||
)
|
||||
@click.option(
|
||||
"--enable-gui-log-window",
|
||||
type=bool,
|
||||
is_flag=False,
|
||||
default=None,
|
||||
help="Enable the gui log window (currently only displays logs; overriding the setting in Serena's config).",
|
||||
)
|
||||
@click.option(
|
||||
"--open-web-dashboard",
|
||||
type=bool,
|
||||
is_flag=False,
|
||||
default=None,
|
||||
help="Open Serena's dashboard in your browser after MCP server startup (overriding the setting in Serena's config).",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
@@ -204,6 +227,7 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
host: str,
|
||||
port: int,
|
||||
enable_web_dashboard: bool | None,
|
||||
open_web_dashboard: bool | None,
|
||||
enable_gui_log_window: bool | None,
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None,
|
||||
trace_lsp_communication: bool | None,
|
||||
@@ -244,6 +268,7 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
modes=modes,
|
||||
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
|
||||
enable_web_dashboard=enable_web_dashboard,
|
||||
open_web_dashboard=open_web_dashboard,
|
||||
enable_gui_log_window=enable_gui_log_window,
|
||||
log_level=log_level,
|
||||
trace_lsp_communication=trace_lsp_communication,
|
||||
@@ -258,7 +283,9 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
server.run(transport=transport)
|
||||
|
||||
@staticmethod
|
||||
@click.command("print-system-prompt", help="Print the system prompt for a project.")
|
||||
@click.command(
|
||||
"print-system-prompt", help="Print the system prompt for a project.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
|
||||
)
|
||||
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
@@ -309,7 +336,7 @@ class ModeCommands(AutoRegisteringGroup):
|
||||
super().__init__(name="mode", help="Manage Serena modes. You can run `mode <command> --help` for more info on each command.")
|
||||
|
||||
@staticmethod
|
||||
@click.command("list", help="List available modes.")
|
||||
@click.command("list", help="List available modes.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
def list() -> None:
|
||||
mode_names = SerenaAgentMode.list_registered_mode_names()
|
||||
max_len_name = max(len(name) for name in mode_names) if mode_names else 20
|
||||
@@ -321,7 +348,7 @@ class ModeCommands(AutoRegisteringGroup):
|
||||
click.echo(name_descr_string)
|
||||
|
||||
@staticmethod
|
||||
@click.command("create", help="Create a new mode or copy an internal one.")
|
||||
@click.command("create", help="Create a new mode or copy an internal one.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.option(
|
||||
"--name",
|
||||
"-n",
|
||||
@@ -350,7 +377,7 @@ class ModeCommands(AutoRegisteringGroup):
|
||||
_open_in_editor(dest)
|
||||
|
||||
@staticmethod
|
||||
@click.command("edit", help="Edit a custom mode YAML file.")
|
||||
@click.command("edit", help="Edit a custom mode YAML file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.argument("mode_name")
|
||||
def edit(mode_name: str) -> None:
|
||||
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
|
||||
@@ -366,7 +393,7 @@ class ModeCommands(AutoRegisteringGroup):
|
||||
_open_in_editor(path)
|
||||
|
||||
@staticmethod
|
||||
@click.command("delete", help="Delete a custom mode file.")
|
||||
@click.command("delete", help="Delete a custom mode file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.argument("mode_name")
|
||||
def delete(mode_name: str) -> None:
|
||||
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
|
||||
@@ -386,7 +413,7 @@ class ContextCommands(AutoRegisteringGroup):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@click.command("list", help="List available contexts.")
|
||||
@click.command("list", help="List available contexts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
def list() -> None:
|
||||
context_names = SerenaAgentContext.list_registered_context_names()
|
||||
max_len_name = max(len(name) for name in context_names) if context_names else 20
|
||||
@@ -398,7 +425,9 @@ class ContextCommands(AutoRegisteringGroup):
|
||||
click.echo(name_descr_string)
|
||||
|
||||
@staticmethod
|
||||
@click.command("create", help="Create a new context or copy an internal one.")
|
||||
@click.command(
|
||||
"create", help="Create a new context or copy an internal one.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
"-n",
|
||||
@@ -427,7 +456,7 @@ class ContextCommands(AutoRegisteringGroup):
|
||||
_open_in_editor(dest)
|
||||
|
||||
@staticmethod
|
||||
@click.command("edit", help="Edit a custom context YAML file.")
|
||||
@click.command("edit", help="Edit a custom context YAML file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.argument("context_name")
|
||||
def edit(context_name: str) -> None:
|
||||
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
|
||||
@@ -443,7 +472,7 @@ class ContextCommands(AutoRegisteringGroup):
|
||||
_open_in_editor(path)
|
||||
|
||||
@staticmethod
|
||||
@click.command("delete", help="Delete a custom context file.")
|
||||
@click.command("delete", help="Delete a custom context file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.argument("context_name")
|
||||
def delete(context_name: str) -> None:
|
||||
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
|
||||
@@ -462,7 +491,9 @@ class SerenaConfigCommands(AutoRegisteringGroup):
|
||||
|
||||
@staticmethod
|
||||
@click.command(
|
||||
"edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found."
|
||||
"edit",
|
||||
help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found.",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
def edit() -> None:
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
@@ -512,7 +543,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
return generated_conf
|
||||
|
||||
@staticmethod
|
||||
@click.command("create", help="Create a new Serena project configuration.")
|
||||
@click.command("create", help="Create a new Serena project configuration.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
|
||||
@click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.")
|
||||
@click.option(
|
||||
@@ -538,7 +569,11 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
@staticmethod
|
||||
@click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.")
|
||||
@click.command(
|
||||
"index",
|
||||
help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
|
||||
@click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).")
|
||||
@click.option(
|
||||
@@ -570,7 +605,9 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
ProjectCommands._index_project(project, log_level, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
@click.command("index-deprecated", help="Deprecated alias for 'serena project index'.")
|
||||
@click.command(
|
||||
"index-deprecated", help="Deprecated alias for 'serena project index'.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
|
||||
)
|
||||
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
|
||||
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING")
|
||||
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
|
||||
@@ -622,7 +659,11 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
ls_mgr.stop_all()
|
||||
|
||||
@staticmethod
|
||||
@click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.")
|
||||
@click.command(
|
||||
"is_ignored_path",
|
||||
help="Check if a path is ignored by the project configuration.",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("path", type=click.Path(exists=False, file_okay=True, dir_okay=True))
|
||||
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
|
||||
def is_ignored_path(path: str, project: str) -> None:
|
||||
@@ -639,7 +680,11 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
click.echo(f"Path '{path}' IS {'ignored' if is_ignored else 'IS NOT ignored'} by the project configuration.")
|
||||
|
||||
@staticmethod
|
||||
@click.command("index-file", help="Index a single file by saving its symbols to the LSP cache.")
|
||||
@click.command(
|
||||
"index-file",
|
||||
help="Index a single file by saving its symbols to the LSP cache.",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("file", type=click.Path(exists=True, file_okay=True, dir_okay=False))
|
||||
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Print detailed information about the indexed symbols.")
|
||||
@@ -672,7 +717,11 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
ls_mgr.stop_all()
|
||||
|
||||
@staticmethod
|
||||
@click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.")
|
||||
@click.command(
|
||||
"health-check",
|
||||
help="Perform a comprehensive health check of the project's tools and language server.",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
|
||||
def health_check(project: str) -> None:
|
||||
"""
|
||||
@@ -827,6 +876,7 @@ class ToolCommands(AutoRegisteringGroup):
|
||||
@click.command(
|
||||
"list",
|
||||
help="Prints an overview of the tools that are active by default (not just the active ones for your project). For viewing all tools, pass `--all / -a`",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.option("--quiet", "-q", is_flag=True)
|
||||
@click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.")
|
||||
@@ -849,6 +899,7 @@ class ToolCommands(AutoRegisteringGroup):
|
||||
@click.command(
|
||||
"description",
|
||||
help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("tool_name", type=str)
|
||||
@click.option("--context", type=str, default=None, help="Context name or path to context file.")
|
||||
@@ -879,7 +930,9 @@ class PromptCommands(AutoRegisteringGroup):
|
||||
return os.path.join(templates_dir, prompt_yaml_name)
|
||||
|
||||
@staticmethod
|
||||
@click.command("list", help="Lists yamls that are used for defining prompts.")
|
||||
@click.command(
|
||||
"list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
|
||||
)
|
||||
def list() -> None:
|
||||
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
|
||||
for prompt_yaml_name in serena_prompt_yaml_names:
|
||||
@@ -890,7 +943,11 @@ class PromptCommands(AutoRegisteringGroup):
|
||||
click.echo(prompt_yaml_name)
|
||||
|
||||
@staticmethod
|
||||
@click.command("create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts")
|
||||
@click.command(
|
||||
"create-override",
|
||||
help="Create an override of an internal prompts yaml for customizing Serena's prompts",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("prompt_yaml_name")
|
||||
def create_override(prompt_yaml_name: str) -> None:
|
||||
"""
|
||||
@@ -908,7 +965,9 @@ class PromptCommands(AutoRegisteringGroup):
|
||||
_open_in_editor(user_prompt_yaml_path)
|
||||
|
||||
@staticmethod
|
||||
@click.command("edit-override", help="Edit an existing prompt override file")
|
||||
@click.command(
|
||||
"edit-override", help="Edit an existing prompt override file", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
|
||||
)
|
||||
@click.argument("prompt_yaml_name")
|
||||
def edit_override(prompt_yaml_name: str) -> None:
|
||||
"""
|
||||
@@ -925,7 +984,7 @@ class PromptCommands(AutoRegisteringGroup):
|
||||
_open_in_editor(user_prompt_yaml_path)
|
||||
|
||||
@staticmethod
|
||||
@click.command("list-overrides", help="List existing prompt override files")
|
||||
@click.command("list-overrides", help="List existing prompt override files", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
def list_overrides() -> None:
|
||||
user_templates_dir = SerenaPaths().user_prompt_templates_dir
|
||||
os.makedirs(user_templates_dir, exist_ok=True)
|
||||
@@ -936,7 +995,7 @@ class PromptCommands(AutoRegisteringGroup):
|
||||
click.echo(file_path)
|
||||
|
||||
@staticmethod
|
||||
@click.command("delete-override", help="Delete a prompt override file")
|
||||
@click.command("delete-override", help="Delete a prompt override file", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@click.argument("prompt_yaml_name")
|
||||
def delete_override(prompt_yaml_name: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -56,6 +56,7 @@ class ResponseConfigOverview(BaseModel):
|
||||
jetbrains_mode: bool
|
||||
languages: list[str]
|
||||
encoding: str | None
|
||||
current_client: str | None
|
||||
|
||||
|
||||
class ResponseAvailableLanguages(BaseModel):
|
||||
@@ -370,6 +371,7 @@ class SerenaDashboardAPI:
|
||||
|
||||
def _get_config_overview(self) -> ResponseConfigOverview:
|
||||
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.tools.tools_base import Tool
|
||||
|
||||
# Get active project info
|
||||
project = self._agent.get_active_project()
|
||||
@@ -491,6 +493,7 @@ class SerenaDashboardAPI:
|
||||
jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS,
|
||||
languages=languages,
|
||||
encoding=encoding,
|
||||
current_client=Tool.get_last_tool_call_client_str(),
|
||||
)
|
||||
|
||||
def _shutdown(self) -> None:
|
||||
|
||||
+8
-1
@@ -239,7 +239,9 @@ class SerenaMCPFactory:
|
||||
parameters=parameters,
|
||||
fn_metadata=func_arg_metadata,
|
||||
is_async=is_async,
|
||||
context_kwarg=None,
|
||||
# keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this
|
||||
# when the tool is constructed via from_function (which is a bit crazy IMO, but well...)
|
||||
context_kwarg="mcp_ctx",
|
||||
annotations=annotations,
|
||||
title=tool_title,
|
||||
)
|
||||
@@ -274,6 +276,7 @@ class SerenaMCPFactory:
|
||||
language_backend: LanguageBackend | None = None,
|
||||
enable_web_dashboard: bool | None = None,
|
||||
enable_gui_log_window: bool | None = None,
|
||||
open_web_dashboard: bool | None = None,
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
||||
trace_lsp_communication: bool | None = None,
|
||||
tool_timeout: float | None = None,
|
||||
@@ -288,6 +291,8 @@ class SerenaMCPFactory:
|
||||
:param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration.
|
||||
:param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then.
|
||||
If not specified, will take the value from the serena configuration.
|
||||
:param open_web_dashboard: Whether to open the web dashboard on launch.
|
||||
If not specified, will take the value from the serena configuration.
|
||||
:param log_level: Log level. If not specified, will take the value from the serena configuration.
|
||||
:param trace_lsp_communication: Whether to trace the communication between Serena and the language servers.
|
||||
This is useful for debugging language server issues.
|
||||
@@ -301,6 +306,8 @@ class SerenaMCPFactory:
|
||||
config.web_dashboard = enable_web_dashboard
|
||||
if enable_gui_log_window is not None:
|
||||
config.gui_log_window_enabled = enable_gui_log_window
|
||||
if open_web_dashboard is not None:
|
||||
config.web_dashboard_open_on_launch = open_web_dashboard
|
||||
if log_level is not None:
|
||||
log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper())
|
||||
config.log_level = logging.getLevelNamesMapping()[log_level]
|
||||
|
||||
+14
-8
@@ -230,15 +230,21 @@ class Project(ToStringMixin):
|
||||
def is_path_in_project(self, path: str | Path) -> bool:
|
||||
"""
|
||||
Checks if the given (absolute or relative) path is inside the project directory.
|
||||
Note that even relative paths may be outside if they contain ".." or point to symlinks.
|
||||
"""
|
||||
path = Path(path)
|
||||
_proj_root = Path(self.project_root)
|
||||
if not path.is_absolute():
|
||||
path = _proj_root / path
|
||||
|
||||
path = path.resolve()
|
||||
return path.is_relative_to(_proj_root)
|
||||
Note: This is intended to catch cases where ".." segments would lead outside of the project directory,
|
||||
but we intentionally allow symlinks, as the assumption is that they point to relevant project files.
|
||||
"""
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.join(self.project_root, path)
|
||||
|
||||
# collapse any ".." or "." segments (purely lexically)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
try:
|
||||
return os.path.commonpath([self.project_root, path]) == self.project_root
|
||||
except ValueError:
|
||||
# occurs, in particular, if paths are on different drives on Windows
|
||||
return False
|
||||
|
||||
def relative_path_exists(self, relative_path: str) -> bool:
|
||||
"""
|
||||
|
||||
@@ -619,6 +619,10 @@ class Dashboard {
|
||||
html += '<div class="config-label">File Encoding:</div>';
|
||||
html += '<div class="config-value">' + (config.encoding || 'N/A') + '</div>';
|
||||
|
||||
// Current Client info
|
||||
html += '<div class="config-label">Current Client:</div>';
|
||||
html += '<div class="config-value">' + (config.current_client || 'None') + '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Active tools - collapsible
|
||||
|
||||
@@ -27,6 +27,12 @@ web_dashboard: True
|
||||
# whether to open the Serena web dashboard (which will be accessible through your web browser) that
|
||||
# provides access to Serena's configuration and state as well as the current session logs.
|
||||
# The web dashboard is supported on all platforms.
|
||||
# We strongly recommend to always enable this option, since the dashboard provides important information
|
||||
# about the current state of Serena, including the configuration, the logs and tool usage statistics.
|
||||
# If you don't want the browser window to pop up automatically, set the `web_dashboard_open_on_launch` to False
|
||||
# (either here or through the corresponding flag in the `start-mcp-server` CLI command).
|
||||
# You can then open the dashboard by asking your agent to do so (e.g., by saying "open the dashboard"), Serena provides
|
||||
# a tool for this.
|
||||
# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html
|
||||
|
||||
web_dashboard_listen_address: 127.0.0.1
|
||||
@@ -34,7 +40,7 @@ web_dashboard_listen_address: 127.0.0.1
|
||||
|
||||
web_dashboard_open_on_launch: True
|
||||
# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard
|
||||
# is enabled).
|
||||
# is enabled). See also the web_dashboard option.
|
||||
# If set to false, you can still open the dashboard manually by
|
||||
# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by
|
||||
# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port
|
||||
|
||||
@@ -4,8 +4,10 @@ from abc import ABC
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast
|
||||
|
||||
from mcp import Implementation
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
||||
from sensai.util import logging
|
||||
from sensai.util.string import dict_string
|
||||
@@ -115,6 +117,17 @@ class Tool(Component):
|
||||
# (which is use by the LLM, so a good description is important)
|
||||
# and to validate the tool call arguments.
|
||||
|
||||
_last_tool_call_client_str: str | None = None
|
||||
"""We can only get the client info from within a tool call. Each tool call will update this variable."""
|
||||
|
||||
@classmethod
|
||||
def set_last_tool_call_client_str(cls, client_str: str | None) -> None:
|
||||
cls._last_tool_call_client_str = client_str
|
||||
|
||||
@classmethod
|
||||
def get_last_tool_call_client_str(cls) -> str | None:
|
||||
return cls._last_tool_call_client_str
|
||||
|
||||
@classmethod
|
||||
def get_name_from_cls(cls) -> str:
|
||||
name = cls.__name__
|
||||
@@ -219,10 +232,21 @@ class Tool(Component):
|
||||
def is_active(self) -> bool:
|
||||
return self.agent.tool_is_active(self.__class__)
|
||||
|
||||
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs) -> str: # type: ignore
|
||||
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore
|
||||
"""
|
||||
Applies the tool with logging and exception handling, using the given keyword arguments
|
||||
"""
|
||||
if mcp_ctx is not None:
|
||||
try:
|
||||
client_params = mcp_ctx.session.client_params
|
||||
if client_params is not None:
|
||||
client_info = cast(Implementation, client_params.clientInfo)
|
||||
client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version
|
||||
if client_str != self.get_last_tool_call_client_str():
|
||||
log.debug(f"Updating client info: {client_info}")
|
||||
self.set_last_tool_call_client_str(client_str)
|
||||
except BaseException as e:
|
||||
log.info(f"Failed to get client info: {e}.")
|
||||
|
||||
def task() -> str:
|
||||
apply_fn = self.get_apply_fn()
|
||||
|
||||
@@ -746,6 +746,7 @@ class EclipseJDTLS(SolidLanguageServer):
|
||||
return
|
||||
|
||||
def lang_status_handler(params: dict) -> None:
|
||||
log.info("Language status update: %s", params)
|
||||
if params["type"] == "ServiceReady" and params["message"] == "ServiceReady":
|
||||
self._service_ready_event.set()
|
||||
if params["type"] == "ProjectStatus":
|
||||
@@ -797,8 +798,22 @@ class EclipseJDTLS(SolidLanguageServer):
|
||||
)
|
||||
assert intellicode_enable_result
|
||||
|
||||
self._service_ready_event.wait()
|
||||
self._project_ready_event.wait()
|
||||
if not self._service_ready_event.is_set():
|
||||
log.info("Waiting for service to be ready ...")
|
||||
self._service_ready_event.wait()
|
||||
log.info("Service is ready")
|
||||
|
||||
if not self._project_ready_event.is_set():
|
||||
log.info("Waiting for project to be ready ...")
|
||||
project_ready_timeout = 20 # Hotfix: Using timeout until we figure out why sometimes we don't get the project ready event
|
||||
if self._project_ready_event.wait(timeout=project_ready_timeout):
|
||||
log.info("Project is ready")
|
||||
else:
|
||||
log.warning("Did not receive project ready status within %d seconds; proceeding anyway", project_ready_timeout)
|
||||
else:
|
||||
log.info("Project is ready")
|
||||
|
||||
log.info("Startup complete")
|
||||
|
||||
@override
|
||||
def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None:
|
||||
|
||||
@@ -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
@@ -1835,6 +1835,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
|
||||
|
||||
@@ -1853,8 +1876,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
|
||||
@@ -1910,7 +1937,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(
|
||||
@@ -1924,7 +1951,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