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}" + )