From 34575f9898b5714457942928b09fb29f622d6ed1 Mon Sep 17 00:00:00 2001 From: meir lixenbrg Date: Sun, 26 Apr 2026 15:34:42 +0300 Subject: [PATCH 1/5] 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) --- CHANGELOG.md | 5 + src/serena/config/serena_config.py | 3 + src/serena/ls_manager.py | 3 + src/serena/project.py | 1 + src/serena/resources/project.template.yml | 11 + .../typescript_language_server.py | 46 ++++- src/solidlsp/ls.py | 195 +++++++++++++++--- src/solidlsp/settings.py | 7 + test/conftest.py | 7 +- .../cross_package_a/shared_utils.ts | 14 ++ .../typescript/cross_package_a/tsconfig.json | 12 ++ .../typescript/cross_package_b/consumer.ts | 10 + .../typescript/cross_package_b/tsconfig.json | 13 ++ .../test_typescript_cross_package.py | 99 +++++++++ 14 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 test/resources/repos/typescript/cross_package_a/shared_utils.ts create mode 100644 test/resources/repos/typescript/cross_package_a/tsconfig.json create mode 100644 test/resources/repos/typescript/cross_package_b/consumer.ts create mode 100644 test/resources/repos/typescript/cross_package_b/tsconfig.json create mode 100644 test/solidlsp/typescript/test_typescript_cross_package.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aea6203e..f9a0389a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5c8e4e10..5e49a2c0 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -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, diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 5c33fa52..11d6feab 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -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, ), ) diff --git a/src/serena/project.py b/src/serena/project.py index a7b15f53..ae062420 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -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) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index ada6fd67..6570c1c0 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -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. diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 5b7ec4fe..e2805b63 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -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 diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6ae69a39..7695b139 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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, ) diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index db975945..8d033d80 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -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) diff --git a/test/conftest.py b/test/conftest.py index 4d3f1154..99ab2845 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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: diff --git a/test/resources/repos/typescript/cross_package_a/shared_utils.ts b/test/resources/repos/typescript/cross_package_a/shared_utils.ts new file mode 100644 index 00000000..a775afbe --- /dev/null +++ b/test/resources/repos/typescript/cross_package_a/shared_utils.ts @@ -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}`; + } +} diff --git a/test/resources/repos/typescript/cross_package_a/tsconfig.json b/test/resources/repos/typescript/cross_package_a/tsconfig.json new file mode 100644 index 00000000..7341858c --- /dev/null +++ b/test/resources/repos/typescript/cross_package_a/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["*.ts"] +} diff --git a/test/resources/repos/typescript/cross_package_b/consumer.ts b/test/resources/repos/typescript/cross_package_b/consumer.ts new file mode 100644 index 00000000..491bb274 --- /dev/null +++ b/test/resources/repos/typescript/cross_package_b/consumer.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(); +} diff --git a/test/resources/repos/typescript/cross_package_b/tsconfig.json b/test/resources/repos/typescript/cross_package_b/tsconfig.json new file mode 100644 index 00000000..44c8ed24 --- /dev/null +++ b/test/resources/repos/typescript/cross_package_b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "rootDir": ".", + "paths": { + "../cross_package_a/*": ["../cross_package_a/*"] + } + }, + "include": ["*.ts"] +} diff --git a/test/solidlsp/typescript/test_typescript_cross_package.py b/test/solidlsp/typescript/test_typescript_cross_package.py new file mode 100644 index 00000000..65582fb0 --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_cross_package.py @@ -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}" + ) From 862af5b42c69708f0cd126d466070e09e787d24b Mon Sep 17 00:00:00 2001 From: meir lixenbrg Date: Sun, 26 Apr 2026 21:55:01 +0300 Subject: [PATCH 2/5] Add documentation for additional_workspace_folders - Add 'Additional Workspace Folders' section to project configuration docs - Add cross-reference note to TypeScript LS settings section - Include configuration example and usage notes --- docs/02-usage/040_workflow.md | 28 ++++++++++++++++++++++++++++ docs/02-usage/050_configuration.md | 4 ++++ 2 files changed, 32 insertions(+) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index ce8fbb77..c5d9cc52 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -48,6 +48,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 name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) * the set of tools and modes to use by default @@ -65,6 +66,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 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 37fb55c0..0c3c5198 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -809,6 +809,10 @@ 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 in monorepos. Configure `additional_workspace_folders` in `project.yml` (not +under `ls_specific_settings`) to enable this feature. + #### TypeScript via `vtsls` The actual configuration key for vtsls is `typescript_vts`, not `vts`. From f0c1568e71a6cdb54ce753b4b8d93891c8efe923 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 4 May 2026 14:20:17 +0200 Subject: [PATCH 3/5] LS: improved handling of additional workspace folders Previously, a field was set outside init and generally a bunch of related code was unnecessary and poorly named. --- src/solidlsp/ls.py | 97 +++++++++++++++++++++------------------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 7695b139..caa65829 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -567,6 +567,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 _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. @@ -595,7 +618,7 @@ class SolidLanguageServer(ABC): # --- Cross-workspace / additional workspace folder support --- @staticmethod - def _is_cross_workspace_path(relative_file_path: str) -> bool: + 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 @@ -606,47 +629,21 @@ class SolidLanguageServer(ABC): 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): + 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 _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. @@ -657,7 +654,7 @@ class SolidLanguageServer(ABC): folders: list[dict[str, str]] = [ {"uri": root_uri, "name": os.path.basename(repository_absolute_path)}, ] - for abs_folder in self._resolve_additional_workspace_folders(): + 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: @@ -676,19 +673,15 @@ class SolidLanguageServer(ABC): 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) + 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", folder) + 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(folder)) + 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() @@ -899,7 +892,7 @@ 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): + if self._path_contains_dots(relative_file_path): absolute_file_path = absolute_file_path.resolve() uri = absolute_file_path.as_uri() @@ -2024,7 +2017,7 @@ class SolidLanguageServer(ABC): 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): + if self._path_contains_dots(ref_path): ref_abs_path = str(pathlib.Path(ref_abs_path).resolve()) location = ls_types.Location( uri=self._resolve_file_uri(ref_path), @@ -2135,7 +2128,7 @@ 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 = os.path.join(self.repository_root_path, relative_file_path) - if self._is_cross_workspace_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() == "": @@ -2252,7 +2245,7 @@ class SolidLanguageServer(ABC): def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]: abs_path = os.path.join(self.repository_root_path, relative_file_path) - if self._is_cross_workspace_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()) From 2c03fe3318578323b2d39327ab70de9bd3426bb0 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 4 May 2026 14:30:40 +0200 Subject: [PATCH 4/5] Docs, minor --- docs/02-usage/050_configuration.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 7d83a86d..e5c28749 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -848,8 +848,7 @@ Supported settings: | `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 in monorepos. Configure `additional_workspace_folders` in `project.yml` (not -under `ls_specific_settings`) to enable this feature. +reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature. #### TypeScript via `vtsls` From 4bd60f18d4ffa8168d957442dc66a044edaa2ea7 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 4 May 2026 19:39:58 +0200 Subject: [PATCH 5/5] Tests: removed csharp tests relying on mocks --- test/solidlsp/csharp/test_csharp_basic.py | 65 +---------------------- 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 97e44e5e..932f73d3 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -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