diff --git a/CHANGELOG.md b/CHANGELOG.md index aea6203e5..f9a0389ad 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/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index ce8fbb779..c5d9cc52a 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 37fb55c0e..0c3c5198a 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`. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5c8e4e104..5e49a2c06 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 @@ def _from_dict(cls, data: dict[str, Any], local_override_keys: list[str]) -> Sel 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 5c33fa524..11d6feab2 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -27,6 +27,7 @@ def __init__( 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 @@ def __init__( 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 @@ def create_language_server(self, language: Language) -> SolidLanguageServer: 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 a7b15f53b..ae062420e 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -660,6 +660,7 @@ def create_language_server_manager(self) -> LanguageServerManager: 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 ada6fd671..6570c1c02 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 5b7ec4fe2..e2805b631 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -252,12 +252,7 @@ def _get_initialize_params(self, repository_absolute_path: str) -> InitializePar "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 @@ def progress_handler(params: dict) -> None: 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 6ae69a39d..7695b139c 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -592,6 +592,154 @@ def _get_wait_time_for_cross_file_referencing(self) -> float: """ 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 @@ def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterato 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 @@ def _open_file_context( 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 @@ def insert_text_at_position(self, relative_file_path: str, line: int, column: in 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 @@ def delete_text_between_positions( 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 @@ def _create_text_document_position_params(self, relative_file_path: str, line: i 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 @@ def request_implementation(self, relative_file_path: str, line: int, column: int 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 @@ def request_completions( :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 @@ def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | lis # 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 @@ def request_signature_help(self, relative_file_path: str, line: int, column: int 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 @@ def request_referencing_symbols( 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 @@ def request_containing_symbol( """ # 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 @@ def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_ 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 @@ def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ # 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 @@ def request_rename_symbol_edit( :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 db9759459..8d033d801 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 4d3f1154d..99ab28453 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 000000000..a775afbef --- /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 000000000..7341858c7 --- /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 000000000..491bb274d --- /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 000000000..44c8ed24c --- /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 000000000..65582fb0a --- /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}" + )