Merge pull request #1418 from meirLixen/feature/cross-package-typescript-references

Add cross-package reference support via additional_workspace_folders
This commit is contained in:
Michael Panchenko authored and GitHub committed 2026-05-04 20:17:03 +02:00
commit e643e91cd7
17 files changed
+418 -95

No files matched your search

+2 -1
View File
@@ -11,7 +11,8 @@ Status of the `main` branch. Changes prior to the next official version change w
Instead, they can define `added_modes` to add modes on top of base and default modes.
See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes).
- Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`.
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
* JetBrains:
- Add new tools:
- `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group
+28
View File
@@ -51,6 +51,7 @@ The file allows you to configure ...
* the encoding used in source files
* ignore rules
* write access
* [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos
* an initial prompt that shall be passed to the LLM whenever the project is activated
* the set of tools and modes to use for the project
* and some other settings.
@@ -68,6 +69,33 @@ You can specify local overrides for the settings in a `project.local.yml` file i
(which, by default, is ignored by git).
Any keys defined therein will override the respective key in `project.yml`.
(additional-workspace-folders)=
#### Additional Workspace Folders (Cross-Package References)
In monorepos or multi-package setups, Serena's language server normally only sees symbols within the
project root. To enable cross-package references (e.g. `find_referencing_symbols` discovering usages
in sibling packages), configure `additional_workspace_folders` in your `project.yml`:
```yaml
additional_workspace_folders:
- ../shared-lib
- ../api-client
- /absolute/path/to/another-package
```
Paths can be absolute or relative to the project root. Each folder is registered as an LSP workspace
folder, and the language server will discover symbols and references across all listed packages.
**Currently supported for:** TypeScript. Other language servers will raise an error if this setting
is used with them. Support for additional languages can be added by implementing the
`_find_representative_source_file()` method in the respective language server class.
:::{note}
Each additional workspace folder adds startup time, as the language server needs to index the
additional projects. For large monorepos, consider listing only the packages you actively need
cross-references for.
:::
(indexing)=
### Indexing
+3
View File
@@ -847,6 +847,9 @@ Supported settings:
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package
reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature.
#### TypeScript via `vtsls`
The actual configuration key for vtsls is `typescript_vts`, not `vts`.
+3
View File
@@ -271,6 +271,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
project_name: str
languages: list[Language]
ignored_paths: list[str] = field(default_factory=list)
additional_workspace_folders: list[str] = field(default_factory=list)
read_only: bool = False
ignore_all_files_in_gitignore: bool = True
initial_prompt: str = ""
@@ -482,6 +483,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
fixed_tools = data["fixed_tools"] or []
excluded_tools = data["excluded_tools"] or []
included_optional_tools = data["included_optional_tools"] or []
additional_workspace_folders = data.get("additional_workspace_folders") or []
if "base_modes" in data and data["base_modes"] is not None:
log.warning("The base_modes setting in project.yml is deprecated and will be ignored.")
@@ -490,6 +492,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
project_name=data["project_name"],
languages=languages,
ignored_paths=ignored_paths,
additional_workspace_folders=additional_workspace_folders,
excluded_tools=excluded_tools,
fixed_tools=fixed_tools,
included_optional_tools=included_optional_tools,
+3
View File
@@ -27,6 +27,7 @@ class LanguageServerFactory:
ignored_patterns: list[str],
ls_timeout: float | None = None,
ls_specific_settings: dict | None = None,
additional_workspace_folders: list[str] | None = None,
trace_lsp_communication: bool = False,
):
self.project_root = project_root
@@ -35,6 +36,7 @@ class LanguageServerFactory:
self.ignored_patterns = ignored_patterns
self.ls_timeout = ls_timeout
self.ls_specific_settings = ls_specific_settings
self.additional_workspace_folders = additional_workspace_folders or []
self.trace_lsp_communication = trace_lsp_communication
def create_language_server(self, language: Language) -> SolidLanguageServer:
@@ -54,6 +56,7 @@ class LanguageServerFactory:
solidlsp_dir=SerenaPaths().serena_user_home_dir,
project_data_path=self.project_data_path,
ls_specific_settings=self.ls_specific_settings or {},
additional_workspace_folders=self.additional_workspace_folders,
),
)
+1
View File
@@ -660,6 +660,7 @@ class Project(ToStringMixin):
ignored_patterns=self._ignored_patterns,
ls_timeout=ls_timeout,
ls_specific_settings=ls_specific_settings,
additional_workspace_folders=self.project_config.additional_workspace_folders,
trace_lsp_communication=self.serena_config.trace_lsp_communication,
)
self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory)
+11
View File
@@ -55,6 +55,17 @@ ignore_all_files_in_gitignore: true
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
@@ -261,12 +261,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
"workspaceFolders": self._build_workspace_folders_param(repository_absolute_path),
}
return cast(InitializeParams, initialize_params)
@@ -407,6 +402,45 @@ class TypeScriptLanguageServer(SolidLanguageServer):
self.INDEXING_PROGRESS_TIMEOUT,
)
self._activate_additional_workspaces()
@override
def _find_representative_source_file(self, directory: str) -> str | None:
"""Find a TypeScript file suitable for triggering project loading.
Prefers a file adjacent to tsconfig.json (indicating the project root),
then falls back to the first .ts/.tsx file found.
"""
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
if "tsconfig.json" in files:
for f in files:
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(root, f)
src_dir = os.path.join(root, "src")
if os.path.isdir(src_dir):
for f in os.listdir(src_dir):
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(src_dir, f)
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
for f in files:
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(root, f)
return None
@override
def _signal_expect_indexing(self) -> None:
self.expect_indexing()
@override
def _wait_for_additional_workspace_indexing(self) -> None:
if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT):
log.info("Additional workspace indexing complete")
else:
log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway")
@override
def _get_published_diagnostics_uri(self, request_uri: str) -> str:
if os.name != "nt" or not request_uri.startswith("file:///"):
+165 -23
View File
@@ -576,6 +576,29 @@ class SolidLanguageServer(ABC):
self._has_waited_for_cross_file_references = False
# resolving additional workspace folders
self._additional_workspace_abs_paths: list[str] = []
_seen: set[str] = set()
for additional_workspace_path in self._solidlsp_settings.additional_workspace_folders:
if not additional_workspace_path or additional_workspace_path == ".":
continue
if not os.path.isabs(additional_workspace_path):
additional_workspace_abs_path = os.path.realpath(os.path.join(self.repository_root_path, additional_workspace_path))
else:
additional_workspace_abs_path = str(Path(additional_workspace_path).resolve())
if not os.path.isdir(additional_workspace_abs_path):
log.error(
"additional_workspace_folders: skipping non-existent directory %s (resolved to %s)",
additional_workspace_abs_path,
additional_workspace_abs_path,
)
continue
if additional_workspace_abs_path in _seen:
log.info("additional_workspace_folders: skipping duplicate %s", additional_workspace_path)
continue
_seen.add(additional_workspace_abs_path)
self._additional_workspace_abs_paths.append(additional_workspace_abs_path)
def _observe_server_notification(self, method: str, params: Any) -> None:
"""
Observe notifications sent by the language server.
@@ -983,6 +1006,124 @@ class SolidLanguageServer(ABC):
"""
return 2
# --- Cross-workspace / additional workspace folder support ---
@staticmethod
def _path_contains_dots(relative_file_path: str) -> bool:
"""Check if a relative path traverses outside the workspace root via '..' components."""
return ".." in PurePath(relative_file_path).parts
def _resolve_file_uri(self, relative_file_path: str) -> str:
"""Construct a canonical file URI from a relative path.
For cross-workspace paths containing '..', the path is resolved to
produce a clean URI without '..' segments.
"""
p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path))
if self._path_contains_dots(relative_file_path):
p = p.resolve()
is_outside_of_configured_workspaces = True
configured_workspaces = [*self._additional_workspace_abs_paths, self.repository_root_path]
for workspace in configured_workspaces:
if p.is_relative_to(workspace):
is_outside_of_configured_workspaces = False
break
if is_outside_of_configured_workspaces:
raise ValueError(
f"Path {relative_file_path} contains '..' segments and is outside of configured workspaces. "
f"Configured workspaces: {configured_workspaces}. Resolved path: {p}."
)
return p.as_uri()
def _build_workspace_folders_param(self, repository_absolute_path: str) -> list[dict[str, str]]:
"""Build the ``workspaceFolders`` list for LSP initialization.
Returns a list containing the primary workspace folder followed by any
additional workspace folders configured in settings.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
folders: list[dict[str, str]] = [
{"uri": root_uri, "name": os.path.basename(repository_absolute_path)},
]
for abs_folder in self._additional_workspace_abs_paths:
folder_uri = pathlib.Path(abs_folder).as_uri()
folders.append({"uri": folder_uri, "name": os.path.basename(abs_folder)})
if len(folders) > 1:
log.info("LSP multi-root workspace: %d folders", len(folders))
return folders
def _activate_additional_workspaces(self) -> None:
"""Open a representative file from each additional workspace folder to
trigger project loading in the language server.
Many language servers only load projects on-demand when files are opened.
This method finds a source file in each additional workspace folder via
:meth:`_find_representative_source_file` and opens it, keeping it open
for the lifetime of this language server instance.
Subclasses must override :meth:`_find_representative_source_file` to
enable this feature; the default implementation raises ``NotImplementedError``.
"""
opened_count = 0
for additional_workspace in self._additional_workspace_abs_paths:
source_file = self._find_representative_source_file(additional_workspace)
if source_file is None:
log.warning("No source file found in additional workspace folder: %s", additional_workspace)
continue
rel_path = os.path.relpath(source_file, self.repository_root_path)
log.info("Opening %s to trigger project loading for %s", rel_path, os.path.basename(additional_workspace))
abs_path = pathlib.Path(source_file).resolve()
uri = abs_path.as_uri()
language_id = self._get_language_id_for_file(rel_path)
self._signal_expect_indexing()
fb = LSPFileBuffer(
abs_path=abs_path,
uri=uri,
encoding=self._encoding,
version=0,
language_id=language_id,
ref_count=1,
language_server=self,
open_in_ls=True,
)
self.open_file_buffers[uri] = fb
opened_count += 1
if opened_count > 0:
log.info("Waiting for %d additional workspace(s) to index...", opened_count)
self._wait_for_additional_workspace_indexing()
def _find_representative_source_file(self, directory: str) -> str | None:
"""Find a source file suitable for triggering project loading in the given directory.
Must be overridden by subclasses that support ``additional_workspace_folders``.
Return ``None`` if no suitable file is found.
:param directory: Absolute path to the workspace folder.
:return: Absolute path to a source file, or None.
"""
raise NotImplementedError(
f"{type(self).__name__} does not yet support additional_workspace_folders. "
f"Override _find_representative_source_file() to enable this feature."
)
def _signal_expect_indexing(self) -> None:
"""Signal that new files are about to be opened and async indexing should be awaited.
Override in subclasses that track indexing progress (e.g. via $/progress).
Default implementation is a no-op.
"""
def _wait_for_additional_workspace_indexing(self) -> None:
"""Wait for additional workspace indexing to complete.
Override in subclasses that track indexing progress.
Default implementation is a no-op.
"""
def set_request_timeout(self, timeout: float | None) -> None:
"""
:param timeout: the timeout, in seconds, for requests to the language server.
@@ -1144,6 +1285,8 @@ class SolidLanguageServer(ABC):
raise SolidLSPException("Language Server not started")
absolute_file_path = Path(self.repository_root_path, relative_file_path)
if self._path_contains_dots(relative_file_path):
absolute_file_path = absolute_file_path.resolve()
uri = absolute_file_path.as_uri()
if uri in self.open_file_buffers:
@@ -1191,7 +1334,7 @@ class SolidLanguageServer(ABC):
be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer.
"""
if file_buffer is not None:
expected_uri = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()
expected_uri = self._resolve_file_uri(relative_file_path)
assert file_buffer.uri == expected_uri, f"Inconsistency between provided {file_buffer.uri=} and {expected_uri=}"
if open_in_ls:
file_buffer.ensure_open_in_ls()
@@ -1214,8 +1357,7 @@ class SolidLanguageServer(ABC):
log.error("insert_text_at_position called before Language Server started")
raise SolidLSPException("Language Server not started")
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
uri = pathlib.Path(absolute_file_path).as_uri()
uri = self._resolve_file_uri(relative_file_path)
# Ensure the file is open
assert uri in self.open_file_buffers
@@ -1257,8 +1399,7 @@ class SolidLanguageServer(ABC):
log.error("delete_text_between_positions called before Language Server started")
raise SolidLSPException("Language Server not started")
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
uri = pathlib.Path(absolute_file_path).as_uri()
uri = self._resolve_file_uri(relative_file_path)
# Ensure the file is open
assert uri in self.open_file_buffers
@@ -1447,9 +1588,7 @@ class SolidLanguageServer(ABC):
return cast(
DefinitionParams,
{
LSPConstants.TEXT_DOCUMENT: {
LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
},
LSPConstants.TEXT_DOCUMENT: {LSPConstants.URI: self._resolve_file_uri(relative_file_path)},
LSPConstants.POSITION: {
LSPConstants.LINE: line,
LSPConstants.CHARACTER: column,
@@ -1496,7 +1635,7 @@ class SolidLanguageServer(ABC):
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
return self.server.send.references(
{
"textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))},
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
"position": {"line": line, "character": column},
"context": {"includeDeclaration": False},
}
@@ -1603,7 +1742,7 @@ class SolidLanguageServer(ABC):
:return: A list of completions
"""
with self.open_file(relative_file_path):
open_file_buffer = self.open_file_buffers[pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()]
open_file_buffer = self.open_file_buffers[self._resolve_file_uri(relative_file_path)]
completion_params: LSPTypes.CompletionParams = {
"position": {"line": line, "character": column},
"textDocument": {"uri": open_file_buffer.uri},
@@ -1720,9 +1859,7 @@ class SolidLanguageServer(ABC):
# no cached result, query language server
log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server")
response = self.server.send.document_symbol(
{"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}}
)
response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}})
# Only cache non-empty results. An empty or None response can occur when the language server
# has not yet finished indexing or building the project (e.g. Lean 4 before `lake build`),
@@ -2161,7 +2298,7 @@ class SolidLanguageServer(ABC):
with self.open_file(relative_file_path):
response = self.server.send.signature_help(
{
"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()},
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
"position": {
"line": line,
"character": column,
@@ -2272,10 +2409,13 @@ class SolidLanguageServer(ABC):
if containing_symbol is None and include_file_symbols:
log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead")
fileRange = self._get_range_from_file_content(file_data.contents)
ref_abs_path = os.path.join(self.repository_root_path, ref_path)
if self._path_contains_dots(ref_path):
ref_abs_path = str(pathlib.Path(ref_abs_path).resolve())
location = ls_types.Location(
uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()),
uri=self._resolve_file_uri(ref_path),
range=fileRange,
absolutePath=str(os.path.join(self.repository_root_path, ref_path)),
absolutePath=ref_abs_path,
relativePath=ref_path,
)
name = os.path.splitext(os.path.basename(ref_path))[0]
@@ -2380,7 +2520,9 @@ class SolidLanguageServer(ABC):
"""
# checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor
with self.open_file(relative_file_path):
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
absolute_file_path = os.path.join(self.repository_root_path, relative_file_path)
if self._path_contains_dots(relative_file_path):
absolute_file_path = str(pathlib.Path(absolute_file_path).resolve())
content = FileUtils.read_file(absolute_file_path, self._encoding)
if content.split("\n")[line].strip() == "":
log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}")
@@ -2495,7 +2637,9 @@ class SolidLanguageServer(ABC):
return definitions[0]
def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]:
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
abs_path = os.path.join(self.repository_root_path, relative_file_path)
if self._path_contains_dots(relative_file_path):
abs_path = str(pathlib.Path(abs_path).resolve())
document_symbols = self.request_document_symbols(relative_file_path)
symbols = list(document_symbols.iter_symbols())
@@ -2503,9 +2647,9 @@ class SolidLanguageServer(ABC):
# symbol exposes a normalized location/range in the current workspace.
for symbol in symbols:
location = symbol["location"]
location["absolutePath"] = absolute_file_path
location["absolutePath"] = abs_path
location["relativePath"] = relative_file_path
location["uri"] = Path(absolute_file_path).as_uri()
location["uri"] = self._resolve_file_uri(relative_file_path)
return symbols
@staticmethod
@@ -2933,9 +3077,7 @@ class SolidLanguageServer(ABC):
:return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported
"""
params = RenameParams(
textDocument=ls_types.TextDocumentIdentifier(
uri=pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()
),
textDocument=ls_types.TextDocumentIdentifier(uri=self._resolve_file_uri(relative_file_path)),
position=ls_types.Position(line=line, character=column),
newName=new_name,
)
+7
View File
@@ -34,6 +34,13 @@ class SolidLSPSettings:
Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available.
No documentation on options means no options are available.
"""
additional_workspace_folders: list[str] = field(default_factory=list)
"""
Additional workspace folder paths for cross-package reference support.
Paths can be absolute or relative to the project root. Each folder is added as a workspace
folder in the LSP initialization, enabling language servers to discover symbols and references
across package boundaries (e.g. in monorepos).
"""
def __post_init__(self) -> None:
os.makedirs(str(self.solidlsp_dir), exist_ok=True)
+6 -1
View File
@@ -61,6 +61,7 @@ def _create_ls(
ignored_paths: list[str] | None = None,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
additional_workspace_folders: list[str] | None = None,
solidlsp_dir: Path | None = None,
) -> SolidLanguageServer:
ignored_paths = ignored_paths or []
@@ -83,6 +84,7 @@ def _create_ls(
solidlsp_dir=effective_solidlsp_dir,
project_data_path=project_data_path,
ls_specific_settings=ls_specific_settings or {},
additional_workspace_folders=additional_workspace_folders or [],
),
)
@@ -94,9 +96,12 @@ def start_ls_context(
ignored_paths: list[str] | None = None,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
additional_workspace_folders: list[str] | None = None,
solidlsp_dir: Path | None = None,
) -> Iterator[SolidLanguageServer]:
ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, solidlsp_dir)
ls = _create_ls(
language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, additional_workspace_folders, solidlsp_dir
)
log.info(f"Starting language server for {language} {repo_path}")
ls.start()
try:
@@ -0,0 +1,14 @@
export function sharedUtilityFunction(input: string): string {
return `processed: ${input}`;
}
export class SharedClass {
name: string;
constructor(name: string) {
this.name = name;
}
greet(): string {
return `Hello, ${this.name}`;
}
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"]
}
@@ -0,0 +1,10 @@
import { sharedUtilityFunction, SharedClass } from "../cross_package_a/shared_utils";
export function consumeSharedUtil(): string {
return sharedUtilityFunction("test data");
}
export function consumeSharedClass(): string {
const instance = new SharedClass("World");
return instance.greet();
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"rootDir": ".",
"paths": {
"../cross_package_a/*": ["../cross_package_a/*"]
}
},
"include": ["*.ts"]
}
+1 -64
View File
@@ -2,22 +2,17 @@ import os
import tempfile
from pathlib import Path
from typing import cast
from unittest.mock import Mock, patch
import pytest
from sensai.util import logging
from serena.util.logging import SuspendedLoggersContext
from solidlsp import SolidLanguageServer
from solidlsp.language_servers.csharp_language_server import (
CSharpLanguageServer,
breadth_first_file_scan,
find_solution_or_project_file,
)
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
from solidlsp.settings import SolidLSPSettings
from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -314,64 +309,6 @@ class TestCSharpSolutionProjectOpening:
# Should still prefer .sln file even though it's deeper
assert result == str(solution_file)
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server")
def test_csharp_language_server_logs_solution_discovery(self, mock_start_server, mock_ensure_server_installed):
"""Test that CSharpLanguageServer logs solution/project discovery during initialization."""
mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll")
# Create test directory with solution file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
solution_file = temp_path / "TestSolution.sln"
solution_file.touch()
mock_config = Mock(spec=LanguageServerConfig)
mock_config.ignored_paths = []
# Create CSharpLanguageServer instance
mock_settings = Mock(spec=SolidLSPSettings)
mock_settings.ls_resources_dir = "/tmp/test_ls_resources"
mock_settings.project_data_path = str(temp_path / "project_data")
with SuspendedLoggersContext():
logging.getLogger().setLevel(logging.DEBUG)
with logging.MemoryLoggerContext() as mem_log:
CSharpLanguageServer(mock_config, str(temp_path), mock_settings)
# Verify that logger was called with solution file discovery
expected_log_msg = f"Found solution/project file: {solution_file}"
assert expected_log_msg in mem_log.get_log()
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server")
def test_csharp_language_server_logs_no_solution_warning(self, mock_start_server, mock_ensure_server_installed):
"""Test that CSharpLanguageServer logs warning when no solution/project files are found."""
# Mock the server installation
mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll")
# Create empty test directory
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Mock logger to capture log messages
mock_config = Mock(spec=LanguageServerConfig)
mock_config.ignored_paths = []
mock_settings = Mock(spec=SolidLSPSettings)
mock_settings.ls_resources_dir = "/tmp/test_ls_resources"
mock_settings.project_data_path = str(temp_path / "project_data")
# Create CSharpLanguageServer instance
with SuspendedLoggersContext():
logging.getLogger().setLevel(logging.DEBUG)
with logging.MemoryLoggerContext() as mem_log:
CSharpLanguageServer(mock_config, str(temp_path), mock_settings)
# Verify that logger was called with warning about no solution/project files
expected_log_msg = "No .sln/.slnx or .csproj file found, language server will attempt auto-discovery"
assert expected_log_msg in mem_log.get_log()
def test_solution_and_project_opening_with_real_test_repo(self):
"""Test solution and project opening with the actual C# test repository."""
# Get the C# test repo path
@@ -0,0 +1,99 @@
"""Tests for cross-package TypeScript references using additional_workspace_folders."""
from pathlib import Path
import pytest
from solidlsp.ls_config import Language
from test.conftest import start_ls_context
CROSS_PKG_DIR = Path(__file__).parent.parent.parent / "resources" / "repos" / "typescript"
PACKAGE_A = str(CROSS_PKG_DIR / "cross_package_a")
PACKAGE_B = str(CROSS_PKG_DIR / "cross_package_b")
@pytest.mark.typescript
class TestCrossPackageReferences:
"""Verify that find_referencing_symbols works across package boundaries
when additional_workspace_folders is configured.
"""
def test_cross_package_find_references(self) -> None:
"""Starting from package_a, with package_b as additional workspace,
references in package_b should be discovered.
"""
with start_ls_context(
Language.TYPESCRIPT,
repo_path=PACKAGE_A,
additional_workspace_folders=[PACKAGE_B],
) as ls:
symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots()
shared_fn = None
for sym in symbols[0]:
if sym.get("name") == "sharedUtilityFunction":
shared_fn = sym
break
assert shared_fn is not None, "Could not find 'sharedUtilityFunction' in shared_utils.ts"
sel_start = shared_fn["selectionRange"]["start"]
refs = ls.request_references("shared_utils.ts", sel_start["line"], sel_start["character"])
ref_paths = [r.get("relativePath", "") for r in refs]
cross_package_refs = [p for p in ref_paths if "cross_package_b" in p or "consumer.ts" in p]
assert len(cross_package_refs) > 0, (
f"Expected cross-package reference from package_b/consumer.ts, but only found refs in: {ref_paths}"
)
def test_cross_package_referencing_symbols(self) -> None:
"""Test the higher-level request_referencing_symbols across packages."""
with start_ls_context(
Language.TYPESCRIPT,
repo_path=PACKAGE_A,
additional_workspace_folders=[PACKAGE_B],
) as ls:
symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots()
shared_class = None
for sym in symbols[0]:
if sym.get("name") == "SharedClass":
shared_class = sym
break
assert shared_class is not None, "Could not find 'SharedClass' in shared_utils.ts"
sel_start = shared_class["selectionRange"]["start"]
ref_symbols = ls.request_referencing_symbols(
"shared_utils.ts",
sel_start["line"],
sel_start["character"],
include_imports=True,
include_file_symbols=True,
)
ref_files = [
r.symbol["location"]["relativePath"]
for r in ref_symbols
if "location" in r.symbol and "relativePath" in r.symbol["location"]
]
cross_refs = [p for p in ref_files if "cross_package_b" in p or "consumer.ts" in p]
assert len(cross_refs) > 0, f"Expected cross-package referencing symbol from package_b, but only found refs in: {ref_files}"
def test_without_additional_workspace_no_cross_refs(self) -> None:
"""Baseline: without additional_workspace_folders, cross-package refs should NOT appear."""
with start_ls_context(
Language.TYPESCRIPT,
repo_path=PACKAGE_A,
) as ls:
symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots()
shared_fn = None
for sym in symbols[0]:
if sym.get("name") == "sharedUtilityFunction":
shared_fn = sym
break
assert shared_fn is not None
sel_start = shared_fn["selectionRange"]["start"]
refs = ls.request_references("shared_utils.ts", sel_start["line"], sel_start["character"])
ref_paths = [r.get("relativePath", "") for r in refs]
cross_package_refs = [p for p in ref_paths if "cross_package_b" in p or "consumer.ts" in p]
assert len(cross_package_refs) == 0, (
f"Without additional_workspace_folders, should NOT find cross-package refs, but found: {cross_package_refs}"
)