Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 146 additions & 6 deletions src/solidlsp/language_servers/typescript_language_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
You can pass the following entries in ls_specific_settings["typescript"]:
- typescript_version: Version of TypeScript to install (default: "5.9.3")
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
- additional_workspace_folders: List of paths to additional workspace folders 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, and a representative .ts file is opened from
each to trigger tsserver project loading.
"""

# Safety timeout for $/progress-based indexing wait. Normally the event fires
Expand All @@ -84,6 +88,7 @@ def __init__(self, config: LanguageServerConfig, repository_root_path: str, soli
)
self.server_ready = threading.Event()
self.initialize_searcher_command_available = threading.Event()
self._additional_workspace_folders: list[str] | None = None

# Progress tracking for $/progress notifications (project indexing, etc.)
self._progress_lock = threading.Lock()
Expand Down Expand Up @@ -217,11 +222,66 @@ def _get_or_install_core_dependency(self) -> str:
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]

def _resolve_additional_workspace_folders(self) -> list[str]:
"""Resolve additional_workspace_folders from ls_specific_settings.

Paths are resolved relative to the repository root. Returns a deduplicated
list of absolute, resolved directory paths (non-existent paths are skipped
with a warning). Results are cached after first call.
"""
if self._additional_workspace_folders is not None:
return self._additional_workspace_folders

raw_folders: list[str] = self._custom_settings.get("additional_workspace_folders", [])
if not raw_folders:
self._additional_workspace_folders = []
return self._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._additional_workspace_folders = resolved
return self._additional_workspace_folders

def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the TypeScript Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
workspace_folders = [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
]

# Add additional workspace folders for cross-package reference support
for abs_folder in self._resolve_additional_workspace_folders():
folder_uri = pathlib.Path(abs_folder).as_uri()
workspace_folders.append(
{
"uri": folder_uri,
"name": os.path.basename(abs_folder),
}
)

if len(workspace_folders) > 1:
log.info("TypeScript LSP multi-root workspace: %d folders", len(workspace_folders))

initialize_params = {
"locale": "en",
"capabilities": {
Expand Down Expand Up @@ -252,12 +312,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": workspace_folders,
}
return cast(InitializeParams, initialize_params)

Expand Down Expand Up @@ -393,6 +448,91 @@ def progress_handler(params: dict) -> None:
self.INDEXING_PROGRESS_TIMEOUT,
)

# Trigger project loading for additional workspace folders by opening a
# representative file from each one. tsserver only loads projects
# on-demand when files are opened; without this, cross-package references
# won't be discovered.
self._open_additional_workspace_files()

def _find_representative_ts_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.
"""
# First try: look for a tsconfig.json and find a .ts file next to it
for root, dirs, files in os.walk(directory):
# skip node_modules, dist, build, etc.
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)
# tsconfig found but no .ts files right here; look in src/ subdirectory
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)

# Fallback: any .ts file
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

def _open_additional_workspace_files(self) -> None:
"""Open a file from each additional workspace folder to trigger tsserver project loading.

Files are registered in ``open_file_buffers`` with a permanent ref_count
so they stay open for the lifetime of this language server instance.
"""
from solidlsp.ls import LSPFileBuffer

additional_folders = self._resolve_additional_workspace_folders()
if not additional_folders:
return

opened_count = 0
for folder in additional_folders:
ts_file = self._find_representative_ts_file(folder)
if ts_file is None:
log.warning("No TypeScript file found in additional workspace folder: %s", folder)
continue

rel_path = os.path.relpath(ts_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(ts_file).resolve()
uri = abs_path.as_uri()
language_id = "typescript" if ts_file.endswith(".ts") else "typescriptreact"

self.expect_indexing()
# Register in open_file_buffers so subsequent open_file() calls
# find the existing buffer instead of sending a duplicate didOpen.
fb = LSPFileBuffer(
abs_path=abs_path,
uri=uri,
encoding=self._encoding,
version=0,
language_id=language_id,
ref_count=1, # permanent reference - stays open
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)
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
Expand Down
63 changes: 40 additions & 23 deletions src/solidlsp/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,22 @@ def _get_wait_time_for_cross_file_referencing(self) -> float:
"""
return 2

@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 set_request_timeout(self, timeout: float | None) -> None:
"""
:param timeout: the timeout, in seconds, for requests to the language server.
Expand Down Expand Up @@ -751,6 +767,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:
Expand Down Expand Up @@ -798,7 +816,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()
Expand All @@ -821,8 +839,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
Expand Down Expand Up @@ -864,8 +881,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
Expand Down Expand Up @@ -1054,9 +1070,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,
Expand Down Expand Up @@ -1103,7 +1117,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},
}
Expand Down Expand Up @@ -1210,7 +1224,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},
Expand Down Expand Up @@ -1327,9 +1341,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`),
Expand Down Expand Up @@ -1768,7 +1780,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,
Expand Down Expand Up @@ -1879,10 +1891,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]
Expand Down Expand Up @@ -1987,7 +2002,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=}")
Expand Down Expand Up @@ -2102,17 +2119,19 @@ 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())

# Make SymbolInformation and DocumentSymbol shapes consistent by ensuring every
# 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
Expand Down Expand Up @@ -2540,9 +2559,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,
)
Expand Down
14 changes: 14 additions & 0 deletions test/resources/repos/typescript/cross_package_a/shared_utils.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
12 changes: 12 additions & 0 deletions test/resources/repos/typescript/cross_package_a/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"]
}
Loading
Loading