mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 22:17:35 +00:00
Add support for additional_workspace_folders, currently only for TS
The TypeScript language server was initialized with a single workspace folder, so tsserver could not discover references across sibling packages in a monorepo. This adds an `additional_workspace_folders` setting in `ls_specific_settings` that accepts a list of directory paths. Each folder is registered as an LSP workspace folder during initialization, and a representative .ts file is opened from each to trigger tsserver project loading. This enables `find_referencing_symbols` and `request_references` to return results from across package boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7a8b5fb212
commit
34575f9898
14 files changed
+396
-30
No files matched your search
@@ -3,6 +3,11 @@
|
||||
Status of the `main` branch. Changes prior to the next official version change will appear here.
|
||||
|
||||
* General:
|
||||
- Add cross-package reference support via `additional_workspace_folders` project setting (currently implemented for TypeScript).
|
||||
Configure additional workspace folder paths in `project.yml` to enable `find_referencing_symbols` and `request_references`
|
||||
to discover symbols across package boundaries in monorepos. The base `SolidLanguageServer` class provides the workspace
|
||||
folder resolution and activation framework; other language servers can enable support by overriding
|
||||
`_find_representative_source_file()`. #750
|
||||
- Support `serena --version` CLI command for displaying the current version #1347
|
||||
- Fix: Check for ignored path ignored `.git` folder only at the top level, not in every subdirectory (`Project._is_ignored_relative_path`) #1350
|
||||
- `GetSymbolsOverviewTool`: ignored paths were not respected in LSP variant (fix in `SolidLanguageServer`)
|
||||
|
||||
@@ -259,6 +259,7 @@ class ProjectConfig(SharedConfig):
|
||||
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 = ""
|
||||
@@ -470,11 +471,13 @@ class ProjectConfig(SharedConfig):
|
||||
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 []
|
||||
|
||||
return cls(
|
||||
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)
|
||||
|
||||
@@ -53,6 +53,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.
|
||||
|
||||
@@ -252,12 +252,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)
|
||||
|
||||
@@ -393,6 +388,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_wait_time_for_cross_file_referencing(self) -> float:
|
||||
return 2
|
||||
|
||||
+172
-23
@@ -592,6 +592,154 @@ class SolidLanguageServer(ABC):
|
||||
"""
|
||||
return 2
|
||||
|
||||
# --- Cross-workspace / additional workspace folder support ---
|
||||
|
||||
@staticmethod
|
||||
def _is_cross_workspace_path(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._is_cross_workspace_path(relative_file_path):
|
||||
p = p.resolve()
|
||||
return p.as_uri()
|
||||
|
||||
def _resolve_additional_workspace_folders(self) -> list[str]:
|
||||
"""Resolve additional_workspace_folders from settings.
|
||||
|
||||
Returns a deduplicated list of absolute, resolved directory paths.
|
||||
Non-existent paths are skipped with a warning, and paths that overlap
|
||||
with the repository root are ignored. Results are cached after first call.
|
||||
"""
|
||||
if not hasattr(self, "_cached_additional_workspace_folders"):
|
||||
raw_folders = self._solidlsp_settings.additional_workspace_folders
|
||||
if not raw_folders:
|
||||
self._cached_additional_workspace_folders: list[str] = []
|
||||
return self._cached_additional_workspace_folders
|
||||
|
||||
seen: set[str] = set()
|
||||
resolved: list[str] = []
|
||||
repo_real = os.path.realpath(self.repository_root_path)
|
||||
for folder in raw_folders:
|
||||
abs_folder = os.path.realpath(os.path.join(self.repository_root_path, folder))
|
||||
if abs_folder == repo_real:
|
||||
log.info("additional_workspace_folders: skipping %s (same as repository root)", folder)
|
||||
continue
|
||||
if not os.path.isdir(abs_folder):
|
||||
log.warning(
|
||||
"additional_workspace_folders: skipping non-existent directory %s (resolved to %s)",
|
||||
folder,
|
||||
abs_folder,
|
||||
)
|
||||
continue
|
||||
if abs_folder in seen:
|
||||
log.info("additional_workspace_folders: skipping duplicate %s", folder)
|
||||
continue
|
||||
seen.add(abs_folder)
|
||||
resolved.append(abs_folder)
|
||||
|
||||
self._cached_additional_workspace_folders = resolved
|
||||
return self._cached_additional_workspace_folders
|
||||
|
||||
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._resolve_additional_workspace_folders():
|
||||
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``.
|
||||
"""
|
||||
additional_folders = self._resolve_additional_workspace_folders()
|
||||
if not additional_folders:
|
||||
return
|
||||
|
||||
opened_count = 0
|
||||
for folder in additional_folders:
|
||||
source_file = self._find_representative_source_file(folder)
|
||||
if source_file is None:
|
||||
log.warning("No source file found in additional workspace folder: %s", folder)
|
||||
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(folder))
|
||||
|
||||
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.
|
||||
@@ -751,6 +899,8 @@ class SolidLanguageServer(ABC):
|
||||
raise SolidLSPException("Language Server not started")
|
||||
|
||||
absolute_file_path = Path(self.repository_root_path, relative_file_path)
|
||||
if self._is_cross_workspace_path(relative_file_path):
|
||||
absolute_file_path = absolute_file_path.resolve()
|
||||
uri = absolute_file_path.as_uri()
|
||||
|
||||
if uri in self.open_file_buffers:
|
||||
@@ -798,7 +948,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()
|
||||
@@ -821,8 +971,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
|
||||
@@ -864,8 +1013,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
|
||||
@@ -1054,9 +1202,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,
|
||||
@@ -1103,7 +1249,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},
|
||||
}
|
||||
@@ -1210,7 +1356,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},
|
||||
@@ -1327,9 +1473,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`),
|
||||
@@ -1768,7 +1912,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,
|
||||
@@ -1879,10 +2023,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._is_cross_workspace_path(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]
|
||||
@@ -1987,7 +2134,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._is_cross_workspace_path(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=}")
|
||||
@@ -2102,7 +2251,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._is_cross_workspace_path(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())
|
||||
|
||||
@@ -2110,9 +2261,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
|
||||
@@ -2540,9 +2691,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
@@ -57,6 +57,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 []
|
||||
@@ -79,6 +80,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 [],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -90,9 +92,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"]
|
||||
}
|
||||
@@ -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