mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 06:15:43 +00:00
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:
17 files changed
+418
-95
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -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}"
|
||||
)
|
||||
Reference in new issue
Block a user