From f521e5f4a022ca63bebca4d2b4d45900263b512f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 8 Aug 2026 21:41:32 +0800 Subject: [PATCH 01/38] docs: fix star history sampling --- README.md | 2 ++ README.zh-CN.md | 2 ++ scripts/update-repo-charts.py | 29 +++++++++++++++-------------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5477f8b7..78846283 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,8 @@ Lithe is licensed under the [Apache License 2.0](./LICENSE). ## Star History +Each point shows the repository's cumulative Star count at `00:00` Beijing time on that date. The chart starts at zero on August 2, 2026. + diff --git a/README.zh-CN.md b/README.zh-CN.md index c0f88574..9e867ceb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -268,6 +268,8 @@ Lithe 采用 [Apache License 2.0](./LICENSE) 授权。 ## Star History +每个日期点表示北京时间当天 `00:00` 时仓库的累计 Star 数。图表从 2026 年 8 月 2 日的 0 开始。 + diff --git a/scripts/update-repo-charts.py b/scripts/update-repo-charts.py index 28d41d0b..68ff2e36 100644 --- a/scripts/update-repo-charts.py +++ b/scripts/update-repo-charts.py @@ -8,7 +8,7 @@ import json import math from collections import Counter -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -19,6 +19,8 @@ PLOT_RIGHT = 1135 PLOT_TOP = 92 PLOT_BOTTOM = 545 +CHART_TIMEZONE = timezone(timedelta(hours=8)) +STAR_HISTORY_START_DATE = date(2026, 8, 2) def parse_args() -> argparse.Namespace: @@ -51,7 +53,10 @@ def parse_starred_date(entry: dict) -> date | None: return None try: - return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).date() + starred_at = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + if starred_at.tzinfo is None: + starred_at = starred_at.replace(tzinfo=timezone.utc) + return starred_at.astimezone(CHART_TIMEZONE).date() except ValueError: return None @@ -103,24 +108,20 @@ def star_history_svg(repo: str, entries: list[dict], dark: bool) -> str: if (starred_date := parse_starred_date(entry)) is not None ) - today = date.today() - if counts: - first_day = min(counts) - last_day = max(today, max(counts)) - else: - first_day = today - timedelta(days=30) - last_day = today + today = datetime.now(CHART_TIMEZONE).date() + first_day = STAR_HISTORY_START_DATE + last_day = max(first_day, today) span = max((last_day - first_day).days, 1) - cumulative = 0 + cumulative = sum(count for starred_date, count in counts.items() if starred_date < first_day) points: list[tuple[date, int]] = [] current_day = first_day while current_day <= last_day: - cumulative += counts[current_day] points.append((current_day, cumulative)) + cumulative += counts[current_day] current_day += timedelta(days=1) - maximum = nice_maximum(max(cumulative, 1)) + maximum = nice_maximum(max((value for _, value in points), default=1)) plot_width = PLOT_RIGHT - PLOT_LEFT plot_height = PLOT_BOTTOM - PLOT_TOP @@ -146,7 +147,7 @@ def point_for(index: int, value: int) -> tuple[float, float]: svg_text(600, 54, "Star History", text_anchor="middle", font_size="34", font_weight="700", fill=foreground), f'', f'', - svg_text(54, 325, "GitHub Stars", text_anchor="middle", transform="rotate(-90 54 325)", font_size="22", fill=foreground), + svg_text(54, 325, "GitHub Stars at 00:00", text_anchor="middle", transform="rotate(-90 54 325)", font_size="22", fill=foreground), svg_text(625, 635, "Date", text_anchor="middle", font_size="22", fill=foreground), ] @@ -175,7 +176,7 @@ def point_for(index: int, value: int) -> tuple[float, float]: return "\n".join( [ '', - f'', + f'', f'', *elements, "", From 418e5f3c98a3260f5a1f85e6c2e5a4ab553e6bf2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 8 Aug 2026 21:58:53 +0800 Subject: [PATCH 02/38] fix: embed contributor avatars --- scripts/update-repo-charts.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/scripts/update-repo-charts.py b/scripts/update-repo-charts.py index 68ff2e36..0baf55c5 100644 --- a/scripts/update-repo-charts.py +++ b/scripts/update-repo-charts.py @@ -4,12 +4,14 @@ from __future__ import annotations import argparse +import base64 import html import json import math from collections import Counter from datetime import date, datetime, timedelta, timezone from pathlib import Path +from urllib import request from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -101,6 +103,27 @@ def svg_text(x: float, y: float, value: str, **attributes: str) -> str: return f'{html.escape(value)}' +def avatar_data_uri(url: str) -> str | None: + parts = urlsplit(url) + if parts.scheme != "https": + return None + + try: + avatar_request = request.Request( + add_query_parameter(url, "s", "136"), + headers={"User-Agent": "Lithe-IDEA chart generator"}, + ) + with request.urlopen(avatar_request, timeout=10) as response: + content_type = response.headers.get_content_type() + if not content_type.startswith("image/"): + return None + encoded = base64.b64encode(response.read()).decode("ascii") + except (OSError, ValueError): + return None + + return f"data:{content_type};base64,{encoded}" + + def star_history_svg(repo: str, entries: list[dict], dark: bool) -> str: counts = Counter( starred_date @@ -198,6 +221,7 @@ def contributor_svg(repo: str, entries: list[dict]) -> str: rows = max(1, math.ceil(len(contributors) / columns)) width = margin * 2 + columns * avatar_size + (columns - 1) * gap height = margin * 2 + rows * avatar_size + (rows - 1) * gap + avatar_cache: dict[str, str | None] = {} elements = [ f'', f'Contributors to {html.escape(repo, quote=True)}', @@ -214,9 +238,14 @@ def contributor_svg(repo: str, entries: list[dict]) -> str: label = f"{login} ({contributions} contributions)" elements.append(f'') elements.append(f'') + avatar_href = None if isinstance(avatar_url, str) and avatar_url: + if avatar_url not in avatar_cache: + avatar_cache[avatar_url] = avatar_data_uri(avatar_url) + avatar_href = avatar_cache[avatar_url] + if avatar_href: elements.append( - f'' + f'' ) else: elements.append(f'') From 2d5bbf85af1a6c12ca585a09a930e316293edcbd Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 8 Aug 2026 22:16:42 +0800 Subject: [PATCH 03/38] docs: polish star history chart --- README.md | 6 +-- README.zh-CN.md | 6 +-- scripts/update-repo-charts.py | 93 +++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 78846283..224db07a 100644 --- a/README.md +++ b/README.md @@ -271,11 +271,7 @@ Lithe is licensed under the [Apache License 2.0](./LICENSE). Each point shows the repository's cumulative Star count at `00:00` Beijing time on that date. The chart starts at zero on August 2, 2026. - - - - Star History Chart - + Star History Chart ## Contact us diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e867ceb..b0ddb260 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -271,11 +271,7 @@ Lithe 采用 [Apache License 2.0](./LICENSE) 授权。 每个日期点表示北京时间当天 `00:00` 时仓库的累计 Star 数。图表从 2026 年 8 月 2 日的 0 开始。 - - - - Star History 图表 - + Star History 图表 ## 联系我们 diff --git a/scripts/update-repo-charts.py b/scripts/update-repo-charts.py index 0baf55c5..41a3292d 100644 --- a/scripts/update-repo-charts.py +++ b/scripts/update-repo-charts.py @@ -19,8 +19,8 @@ CHART_HEIGHT = 680 PLOT_LEFT = 115 PLOT_RIGHT = 1135 -PLOT_TOP = 92 -PLOT_BOTTOM = 545 +PLOT_TOP = 112 +PLOT_BOTTOM = 535 CHART_TIMEZONE = timezone(timedelta(hours=8)) STAR_HISTORY_START_DATE = date(2026, 8, 2) @@ -124,6 +124,52 @@ def avatar_data_uri(url: str) -> str | None: return f"data:{content_type};base64,{encoded}" +def monotone_curve_path(coordinates: list[tuple[float, float]]) -> tuple[str, list[str]]: + if len(coordinates) == 1: + x, y = coordinates[0] + path = f"M {x:.2f},{y:.2f}" + return path, [] + + intervals = [coordinates[index + 1][0] - coordinates[index][0] for index in range(len(coordinates) - 1)] + slopes = [ + (coordinates[index + 1][1] - coordinates[index][1]) / intervals[index] + for index in range(len(intervals)) + ] + tangents = [slopes[0]] + for index in range(1, len(coordinates) - 1): + previous_slope = slopes[index - 1] + next_slope = slopes[index] + if previous_slope * next_slope <= 0: + tangents.append(0.0) + continue + previous_interval = intervals[index - 1] + next_interval = intervals[index] + previous_weight = 2 * next_interval + previous_interval + next_weight = next_interval + 2 * previous_interval + tangents.append( + (previous_weight + next_weight) + / (previous_weight / previous_slope + next_weight / next_slope) + ) + tangents.append(slopes[-1]) + + commands: list[str] = [] + for index, interval in enumerate(intervals): + x1, y1 = coordinates[index] + x2, y2 = coordinates[index + 1] + commands.append( + " ".join( + [ + "C", + f"{x1 + interval / 3:.2f},{y1 + tangents[index] * interval / 3:.2f}", + f"{x2 - interval / 3:.2f},{y2 - tangents[index + 1] * interval / 3:.2f}", + f"{x2:.2f},{y2:.2f}", + ] + ) + ) + x, y = coordinates[0] + return " ".join([f"M {x:.2f},{y:.2f}", *commands]), commands + + def star_history_svg(repo: str, entries: list[dict], dark: bool) -> str: counts = Counter( starred_date @@ -153,24 +199,35 @@ def point_for(index: int, value: int) -> tuple[float, float]: y = PLOT_BOTTOM - (value / maximum) * plot_height return x, y - path = " ".join( - ("M" if index == 0 else "L") - + f" {point_for(index, value)[0]:.2f},{point_for(index, value)[1]:.2f}" - for index, (_, value) in enumerate(points) + coordinates = [point_for(index, value) for index, (_, value) in enumerate(points)] + path, curve_commands = monotone_curve_path(coordinates) + area_path = " ".join( + [ + f"M {coordinates[0][0]:.2f},{PLOT_BOTTOM:.2f}", + f"L {coordinates[0][0]:.2f},{coordinates[0][1]:.2f}", + *curve_commands, + f"L {coordinates[-1][0]:.2f},{PLOT_BOTTOM:.2f}", + "Z", + ] ) background = "#ffffff" if not dark else "#111827" foreground = "#111111" if not dark else "#f3f4f6" muted = "#4b5563" if not dark else "#d1d5db" - grid = "#d1d5db" if not dark else "#374151" + grid = "#e5e7eb" if not dark else "#374151" accent = "#e34b2d" tick_count = 4 + latest_x, latest_y = coordinates[-1] + latest_label = f"{format_number(points[-1][1])} stars" + latest_label_x = latest_x - 18 + latest_label_y = max(PLOT_TOP + 28, latest_y - 18) elements = [ f'', - svg_text(600, 54, "Star History", text_anchor="middle", font_size="34", font_weight="700", fill=foreground), + svg_text(600, 46, "Star History", text_anchor="middle", font_size="34", font_weight="700", fill=foreground), + svg_text(600, 78, "Cumulative GitHub Stars at 00:00 Beijing time", text_anchor="middle", font_size="17", fill=muted), f'', f'', - svg_text(54, 325, "GitHub Stars at 00:00", text_anchor="middle", transform="rotate(-90 54 325)", font_size="22", fill=foreground), + svg_text(54, 325, "GitHub Stars", text_anchor="middle", transform="rotate(-90 54 325)", font_size="22", fill=foreground), svg_text(625, 635, "Date", text_anchor="middle", font_size="22", fill=foreground), ] @@ -178,9 +235,9 @@ def point_for(index: int, value: int) -> tuple[float, float]: value = int(maximum * tick / tick_count) y = PLOT_BOTTOM - (tick / tick_count) * plot_height elements.append( - f'' + f'' ) - elements.append(svg_text(PLOT_LEFT - 18, y + 7, format_number(value), text_anchor="end", font_size="18", fill=foreground)) + elements.append(svg_text(PLOT_LEFT - 18, y + 6, format_number(value), text_anchor="end", font_size="17", fill=muted)) label_indices = sorted({0, len(points) // 4, len(points) // 2, (len(points) * 3) // 4, len(points) - 1}) for index in label_indices: @@ -189,10 +246,18 @@ def point_for(index: int, value: int) -> tuple[float, float]: elements.extend( [ + f'', f'', - f'', - f'', - svg_text(PLOT_LEFT + 68, PLOT_TOP + 55, repo, font_size="20", fill=foreground), + *[ + f'' + for x, y in coordinates + ], + f'', + f'', + svg_text(latest_label_x - 38, latest_label_y - 9, latest_label, text_anchor="middle", font_size="15", font_weight="700", fill=foreground), + f'', + f'', + svg_text(PLOT_LEFT + 64, PLOT_TOP + 53, repo, font_size="19", font_weight="600", fill=foreground), ] ) From 604c479b7eee14e28ccf08d615afe741feae8c2c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 10 Aug 2026 23:52:58 +0800 Subject: [PATCH 04/38] Move LSP provider catalog into Rust core --- README.md | 8 +- README.zh-CN.md | 8 +- Sources/Lithe/Application/AppServices.swift | 17 +- .../Lithe/Application/JavaFeatureModel.swift | 76 +- .../Lithe/Application/UIFeatureModels.swift | 6 + .../Lithe/Core/Ports/ArchiveEntryReader.swift | 5 - Sources/Lithe/Core/Ports/LanguagePacks.swift | 12 - .../Lithe/Core/Ports/LanguageTooling.swift | 203 ++- Sources/Lithe/Core/Ports/RuntimeLocator.swift | 5 +- .../RustLanguageProviderCatalogSource.swift | 78 ++ Sources/Lithe/Models/AppModel.swift | 46 +- .../Lithe/Models/ProjectRuntimeModels.swift | 21 +- .../Debug/MacJavaDebugAdapterLocator.swift | 134 -- .../FileSystem/MacArchiveEntryReader.swift | 18 - .../Platform/MacOS/MacServiceContainer.swift | 59 +- .../MacOS/Runtime/MacRuntimeDiscovery.swift | 10 - .../MacOS/Runtime/MacRuntimeLocator.swift | 4 - .../Runtime/MacRuntimeToolDiscovery.swift | 16 +- .../JavaImplementationMarkerService.swift | 107 +- .../JavaLanguageProviderRuntime.swift | 304 ----- .../Lithe/Services/JavaLanguageService.swift | 1085 ----------------- .../Lithe/Services/LanguagePackRegistry.swift | 98 +- .../LanguageServerResponseParser.swift | 287 ----- .../LanguageToolingSessionManager.swift | 539 +++----- .../Services/ProjectRuntimeService.swift | 19 +- .../StdioLanguageProviderRuntime.swift | 612 +--------- Sources/Lithe/Views/JavaReferencesView.swift | 5 +- .../Lithe/Views/LSPControlCenterView.swift | 688 +++++++++++ Sources/Lithe/Views/WorkbenchView.swift | 78 +- Sources/LitheRustCore/bridge.c | 9 + Sources/LitheRustCore/include/lithe_bridge.h | 1 + .../RunConfigurationIntegrationTests.swift | 862 ++----------- rust/lithe-core/include/lithe_core.h | 1 + .../resources/lsp/language-providers.json | 400 ++++++ rust/lithe-core/src/ffi.rs | 18 + rust/lithe-core/src/lib.rs | 36 +- rust/lithe-core/src/lsp.rs | 392 ++++++ rust/lithe-core/src/run_configuration.rs | 17 +- scripts/RustCoreBridgeVerification.swift | 20 + scripts/verify-rust-core.sh | 4 + scripts/verify-service-boundaries.sh | 2 +- 41 files changed, 2146 insertions(+), 4164 deletions(-) delete mode 100644 Sources/Lithe/Core/Ports/ArchiveEntryReader.swift create mode 100644 Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift delete mode 100644 Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift delete mode 100644 Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift delete mode 100644 Sources/Lithe/Services/JavaLanguageProviderRuntime.swift delete mode 100644 Sources/Lithe/Services/JavaLanguageService.swift delete mode 100644 Sources/Lithe/Services/LanguageServerResponseParser.swift create mode 100644 Sources/Lithe/Views/LSPControlCenterView.swift create mode 100644 rust/lithe-core/resources/lsp/language-providers.json create mode 100644 rust/lithe-core/src/lsp.rs diff --git a/README.md b/README.md index 5477f8b7..a4b9935d 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected ## Use Lithe -Lithe requires macOS 14 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Semantic navigation requires Eclipse JDT LS. Maven projects need either a project `mvnw` or a system Maven installation. +Lithe requires macOS 14 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Maven projects need either a project `mvnw` or a system Maven installation. Semantic navigation is routed through Lithe's Rust LSP host. Download the latest macOS `.dmg` from [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest). If a release provides architecture-specific installers, choose `arm64` for Apple silicon or `x86_64` for an Intel Mac. Open the disk image, drag `Lithe.app` into `/Applications`, and launch it. @@ -129,12 +129,6 @@ xattr -dr com.apple.quarantine "/Applications/Lithe.app" open "/Applications/Lithe.app" ``` -Install JDT LS with Homebrew: - -```bash -brew install jdtls -``` - After opening a project, use **Settings → Project** to configure the project JDK, Maven, and the JDK used by Maven. Lithe also detects Java and Maven from common system locations. ## Architecture diff --git a/README.zh-CN.md b/README.zh-CN.md index c0f88574..b2ca1870 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -104,7 +104,7 @@ Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ ID ## 如何使用 -Lithe 需要 macOS 14 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;语义导航需要 Eclipse JDT LS;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。 +Lithe 需要 macOS 14 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。语义导航会通过 Lithe 的 Rust LSP host 提供。 从 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest) 下载最新的 macOS `.dmg`。如果该版本提供独立架构安装包,M 系列芯片选择 `arm64`,Intel 芯片选择 `x86_64`。打开磁盘映像,将 `Lithe.app` 拖入 `/Applications` 后启动。 @@ -129,12 +129,6 @@ xattr -dr com.apple.quarantine "/Applications/Lithe.app" open "/Applications/Lithe.app" ``` -使用 Homebrew 安装 JDT LS: - -```bash -brew install jdtls -``` - 打开项目后,在 **Settings → Project** 中配置 Project JDK、Maven 和 Maven 使用的 JDK。Lithe 也会从常见的系统位置自动探测 Java 与 Maven。 ## 架构图 diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/AppServices.swift index d15c5aa5..b56cfa40 100644 --- a/Sources/Lithe/Application/AppServices.swift +++ b/Sources/Lithe/Application/AppServices.swift @@ -16,6 +16,7 @@ final class AppServices { /// registries remain exposed below for source compatibility with existing /// feature models while new composition should use this value. let languagePacks: LanguagePackRegistry + let languageProviderCatalogSource: any LanguageProviderCatalogSource /// Metadata-only provider catalog; providers are activated on demand. let languageProviderCatalog: LanguageProviderCatalog let runToolchainRegistry: RunToolchainRegistry @@ -31,7 +32,6 @@ final class AppServices { let fileStorage: any FileStorage let fileOperations: any WorkspaceFileOperations let projectRuntimeService: ProjectRuntimeService - let javaLanguageService: JavaLanguageService let javaImplementationMarkerService: JavaImplementationMarkerService let mavenService: MavenService let runService: RunService @@ -51,7 +51,8 @@ final class AppServices { let shortcutDetectorFactory: any ShortcutDetectorFactory init( - languageProviderCatalog: LanguageProviderCatalog = .standard, + languageProviderCatalogSource: any LanguageProviderCatalogSource, + languageProviderCatalog: LanguageProviderCatalog? = nil, languagePacks: LanguagePackRegistry? = nil, runToolchainRegistry: RunToolchainRegistry? = nil, languageToolingSessions: LanguageToolingSessionManager? = nil, @@ -66,7 +67,6 @@ final class AppServices { fileStorage: any FileStorage, fileOperations: any WorkspaceFileOperations, projectRuntimeService: ProjectRuntimeService, - javaLanguageService: JavaLanguageService, javaImplementationMarkerService: JavaImplementationMarkerService, mavenService: MavenService, runService: RunService, @@ -85,14 +85,10 @@ final class AppServices { platformUI: any PlatformUI, shortcutDetectorFactory: any ShortcutDetectorFactory ) { + self.languageProviderCatalogSource = languageProviderCatalogSource + let resolvedCatalog = languageProviderCatalog ?? languageProviderCatalogSource.catalog(workspaceURL: nil) let resolvedLanguagePacks = languagePacks ?? LanguagePackRegistry.standard( - catalog: languageProviderCatalog, - runtimes: [ - JavaLanguageProviderRuntime( - service: javaLanguageService, - catalog: languageProviderCatalog - ) - ] + catalog: resolvedCatalog ) self.languagePacks = resolvedLanguagePacks self.languageProviderCatalog = resolvedLanguagePacks.catalog @@ -112,7 +108,6 @@ final class AppServices { self.fileStorage = fileStorage self.fileOperations = fileOperations self.projectRuntimeService = projectRuntimeService - self.javaLanguageService = javaLanguageService self.javaImplementationMarkerService = javaImplementationMarkerService self.mavenService = mavenService self.runService = runService diff --git a/Sources/Lithe/Application/JavaFeatureModel.swift b/Sources/Lithe/Application/JavaFeatureModel.swift index d754a975..47d555a7 100644 --- a/Sources/Lithe/Application/JavaFeatureModel.swift +++ b/Sources/Lithe/Application/JavaFeatureModel.swift @@ -1,16 +1,15 @@ import Combine import Foundation -/// Owns Java-only code vision, inlay hints, Maven integration, and legacy Java -/// debug behavior. Shared LSP navigation and editing live in the generic -/// language-tooling pipeline. +/// Owns Java-only code vision, fallback inlay hints, Maven integration, and +/// legacy Java debug behavior. Java LSP navigation and editing are delegated +/// to the Rust host. @MainActor final class JavaFeatureModel: ObservableObject { @Published private(set) var javaDiagnostics: [URL: [JavaDiagnostic]] = [:] @Published private(set) var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] = [:] @Published private(set) var javaInlayHints: [URL: [JavaInlayHint]] = [:] - private let service: JavaLanguageService private let markerService: JavaImplementationMarkerService private let operations: any JavaMavenOperations private let workspaceOperations: any WorkspaceOperations @@ -23,18 +22,13 @@ final class JavaFeatureModel: ObservableObject { private var debugFeature: JavaDebugFeatureModel? init( - service: JavaLanguageService, markerService: JavaImplementationMarkerService, operations: any JavaMavenOperations, workspaceOperations: any WorkspaceOperations ) { - self.service = service self.markerService = markerService self.operations = operations self.workspaceOperations = workspaceOperations - service.onDiagnostics = { [weak self] fileURL, diagnostics in - self?.javaDiagnostics[fileURL.standardizedFileURL] = diagnostics - } } func configure( @@ -57,8 +51,6 @@ final class JavaFeatureModel: ObservableObject { self.debugFeature = debugFeature } - var statusMessage: String { service.statusMessage } - /// Explicit boundary for Java-only editor adornments and legacy services. /// Callers can avoid scheduling Java work for every supported language. func handles(fileURL: URL) -> Bool { @@ -69,14 +61,9 @@ final class JavaFeatureModel: ObservableObject { handles(fileURL: fileURL) } - func configureProjectRoot(_ url: URL) { - service.configureProjectRoot(url) - } - func stop() { inlayHintTasks.values.forEach { $0.cancel() } inlayHintTasks.removeAll() - service.stop() javaDiagnostics = [:] javaCodeVisionHints = [:] javaInlayHints = [:] @@ -214,8 +201,7 @@ final class JavaFeatureModel: ObservableObject { await self.requestInlayHints( for: document, projectFiles: projectFiles, - workspaceRoot: workspaceRoot, - attempt: 0 + workspaceRoot: workspaceRoot ) self.inlayHintTasks[document.id] = nil } @@ -224,50 +210,15 @@ final class JavaFeatureModel: ObservableObject { private func requestInlayHints( for document: EditorDocument, projectFiles: [URL], - workspaceRoot: URL?, - attempt: Int + workspaceRoot: URL? ) async { guard !Task.isCancelled, documentProvider?()?.id == document.id else { return } - await withCheckedContinuation { continuation in - inlayHints(for: document) { [weak self] result in - guard let self else { - continuation.resume() - return - } - if case .success(let hints) = result { - self.javaInlayHints[document.url.standardizedFileURL] = hints - Task { @MainActor [weak self, weak document] in - guard let self, let document else { - continuation.resume() - return - } - if hints.isEmpty, attempt < 3 { - try? await Task.sleep(for: .milliseconds(900 * (attempt + 1))) - guard !Task.isCancelled else { - continuation.resume() - return - } - await self.requestInlayHints( - for: document, - projectFiles: projectFiles, - workspaceRoot: workspaceRoot, - attempt: attempt + 1 - ) - } else if hints.isEmpty { - await self.applyInlayFallback( - for: document, - projectFiles: projectFiles, - workspaceRoot: workspaceRoot - ) - } - continuation.resume() - } - } else { - continuation.resume() - } - } - } + await applyInlayFallback( + for: document, + projectFiles: projectFiles, + workspaceRoot: workspaceRoot + ) } private func applyInlayFallback( @@ -328,11 +279,4 @@ final class JavaFeatureModel: ObservableObject { ) } - func inlayHints( - for document: EditorDocument, - completion: @escaping (Result<[JavaInlayHint], Error>) -> Void - ) { - service.inlayHints(document: document, completion: completion) - } - } diff --git a/Sources/Lithe/Application/UIFeatureModels.swift b/Sources/Lithe/Application/UIFeatureModels.swift index 8bcf07b6..1bec16d3 100644 --- a/Sources/Lithe/Application/UIFeatureModels.swift +++ b/Sources/Lithe/Application/UIFeatureModels.swift @@ -354,4 +354,10 @@ final class RuntimeSettingsFeatureModel: ObservableObject { func mavenExecutable(for project: MavenProject) -> URL? { service.mavenExecutable(for: project) } + func executableCandidates(_ command: String) -> [RuntimeToolCandidate] { + service.executableCandidates(command) + } + func toolGuidance(_ command: String) -> RuntimeToolGuidance { + service.toolGuidance(command) + } } diff --git a/Sources/Lithe/Core/Ports/ArchiveEntryReader.swift b/Sources/Lithe/Core/Ports/ArchiveEntryReader.swift deleted file mode 100644 index df792fad..00000000 --- a/Sources/Lithe/Core/Ports/ArchiveEntryReader.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -protocol ArchiveEntryReader: Sendable { - func read(entry: String, from archive: URL) -> String? -} diff --git a/Sources/Lithe/Core/Ports/LanguagePacks.swift b/Sources/Lithe/Core/Ports/LanguagePacks.swift index 0867e228..7bf6815e 100644 --- a/Sources/Lithe/Core/Ports/LanguagePacks.swift +++ b/Sources/Lithe/Core/Ports/LanguagePacks.swift @@ -1,13 +1,5 @@ import Foundation -/// Process launch metadata shared by the platform runtimes. These values are -/// intentionally just names and arguments; resolving an executable and -/// starting a process remains a platform responsibility. -struct StdioLanguageServerLaunch: Sendable, Equatable { - let executableNames: [String] - let arguments: [String] -} - struct StdioDebugAdapterLaunch: Sendable, Equatable { struct Fallback: Sendable, Equatable { let executableName: String @@ -42,7 +34,6 @@ struct LanguagePack { let descriptor: LanguageProviderDescriptor let runProvider: (any LanguageRunProvider)? let toolchainProviders: [any RunToolchainProvider] - let languageServerLaunch: StdioLanguageServerLaunch? let debugAdapterLaunch: StdioDebugAdapterLaunch? let toolingRuntime: (any LanguageProviderRuntime)? let testProviders: [any LanguageTestProvider] @@ -51,7 +42,6 @@ struct LanguagePack { descriptor: LanguageProviderDescriptor, runProvider: (any LanguageRunProvider)? = nil, toolchainProviders: [any RunToolchainProvider] = [], - languageServerLaunch: StdioLanguageServerLaunch? = nil, debugAdapterLaunch: StdioDebugAdapterLaunch? = nil, toolingRuntime: (any LanguageProviderRuntime)? = nil, testProviders: [any LanguageTestProvider] = [] @@ -59,7 +49,6 @@ struct LanguagePack { self.descriptor = descriptor self.runProvider = runProvider self.toolchainProviders = toolchainProviders - self.languageServerLaunch = languageServerLaunch self.debugAdapterLaunch = debugAdapterLaunch self.toolingRuntime = toolingRuntime self.testProviders = testProviders @@ -70,7 +59,6 @@ struct LanguagePack { descriptor: descriptor, runProvider: runProvider, toolchainProviders: toolchainProviders, - languageServerLaunch: languageServerLaunch, debugAdapterLaunch: debugAdapterLaunch, toolingRuntime: runtime, testProviders: testProviders diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 726ad3de..bdc8168c 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -8,6 +8,25 @@ struct LanguageToolingCapability: OptionSet, Hashable, Sendable { static let debugAdapter = Self(rawValue: 1 << 2) static let formatting = Self(rawValue: 1 << 3) static let testing = Self(rawValue: 1 << 4) + + static func named(_ name: String) -> Self? { + switch name { + case "run": .run + case "languageServer": .languageServer + case "debugAdapter": .debugAdapter + case "formatting": .formatting + case "testing": .testing + default: nil + } + } + + static func names(_ names: [String]) -> Self { + names.reduce(into: Self()) { capabilities, name in + if let capability = Self.named(name) { + capabilities.insert(capability) + } + } + } } struct LanguageServerFeatureSet: OptionSet, Hashable, Sendable { @@ -33,8 +52,6 @@ struct LanguageServerFeatureSet: OptionSet, Hashable, Sendable { } enum ToolingActivationPolicy: String, Codable, Hashable, Sendable { - /// Descriptor-only. No runtime process is created until a file or command - /// explicitly asks for this provider. case onDemand case always } @@ -43,36 +60,71 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { let id: String let displayName: String let fileExtensions: Set + let fileNames: Set + let fileNamePrefixes: Set let capabilities: LanguageToolingCapability let activationPolicy: ToolingActivationPolicy + let languageIdentifier: String? + let languageIdentifiersByExtension: [String: String] + let languageIdentifiersByFileName: [String: String] + + init( + id: String, + displayName: String, + fileExtensions: Set, + fileNames: Set = [], + fileNamePrefixes: Set = [], + capabilities: LanguageToolingCapability, + activationPolicy: ToolingActivationPolicy, + languageIdentifier: String? = nil, + languageIdentifiersByExtension: [String: String] = [:], + languageIdentifiersByFileName: [String: String] = [:] + ) { + self.id = id + self.displayName = displayName + self.fileExtensions = Set(fileExtensions.map { $0.lowercased() }) + self.fileNames = Set(fileNames.map { $0.lowercased() }) + self.fileNamePrefixes = Set(fileNamePrefixes.map { $0.lowercased() }) + self.capabilities = capabilities + self.activationPolicy = activationPolicy + self.languageIdentifier = languageIdentifier + self.languageIdentifiersByExtension = Dictionary( + uniqueKeysWithValues: languageIdentifiersByExtension.map { + ($0.key.lowercased(), $0.value) + } + ) + self.languageIdentifiersByFileName = Dictionary( + uniqueKeysWithValues: languageIdentifiersByFileName.map { + ($0.key.lowercased(), $0.value) + } + ) + } func handles(fileURL: URL) -> Bool { - fileExtensions.contains(fileURL.pathExtension.lowercased()) + let fileName = fileURL.lastPathComponent.lowercased() + return fileExtensions.contains(fileURL.pathExtension.lowercased()) + || fileNames.contains(fileName) + || fileNamePrefixes.contains { fileName.hasPrefix($0) } } func languageIdentifier(for fileURL: URL) -> String { - switch (id, fileURL.pathExtension.lowercased()) { - case ("node", "ts"): "typescript" - case ("node", "tsx"): "typescriptreact" - case ("node", "jsx"): "javascriptreact" - case ("node", _): "javascript" - default: id - } + let extensionName = fileURL.pathExtension.lowercased() + let fileName = fileURL.lastPathComponent.lowercased() + return languageIdentifiersByFileName[fileName] + ?? languageIdentifiersByExtension[extensionName] + ?? languageIdentifier + ?? id } } -/// The catalog is metadata only. Concrete LSP and DAP sessions are injected -/// by a platform/provider adapter when a capability is used. struct LanguageProviderCatalog: Sendable { let descriptors: [LanguageProviderDescriptor] - static let standard = LanguageProviderCatalog(descriptors: [ + /// Minimal fallback used only when the Rust core is not linked. The full + /// market language catalog is registered by Rust's dedicated LSP config. + static let compatibilityFallback = LanguageProviderCatalog(descriptors: [ LanguageProviderDescriptor( id: "java", displayName: "Java", fileExtensions: ["java"], - // Java debugging still uses the legacy JDB integration. Keep it - // out of the generic DAP capability until a real Java DAP runtime - // is injected, so metadata cannot promise a session that does not - // exist. capabilities: [.run, .languageServer, .formatting, .testing], activationPolicy: .onDemand ), @@ -89,13 +141,19 @@ struct LanguageProviderCatalog: Sendable { LanguageProviderDescriptor( id: "node", displayName: "Node.js", fileExtensions: ["js", "jsx", "ts", "tsx", "mjs", "cjs"], capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], - activationPolicy: .onDemand + activationPolicy: .onDemand, + languageIdentifier: "javascript", + languageIdentifiersByExtension: [ + "ts": "typescript", + "tsx": "typescriptreact", + "jsx": "javascriptreact" + ] ), LanguageProviderDescriptor( id: "rust", displayName: "Rust", fileExtensions: ["rs"], capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], activationPolicy: .onDemand - ) + ), ]) func provider(for fileURL: URL) -> LanguageProviderDescriptor? { @@ -103,24 +161,6 @@ struct LanguageProviderCatalog: Sendable { } } -@MainActor -protocol LanguageServerSession: AnyObject { - var isRunning: Bool { get } - var isReady: Bool { get } - func start(rootURL: URL) throws - func stop() -} - -@MainActor -protocol LanguageServerFeatureReportingSession: LanguageServerSession { - var supportedFeatures: LanguageServerFeatureSet { get } - var onSupportedFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get set } -} - -extension LanguageServerSession { - var isReady: Bool { isRunning } -} - struct LanguageServerPosition: Equatable, Sendable { let line: Int let utf16Column: Int @@ -231,9 +271,6 @@ enum LanguageTestScope: Equatable, Sendable { case testCase(identifier: String, fileURL: URL?) } -/// Inputs available to a test provider when it selects a framework-specific -/// runner. The provider receives metadata only; reading files or starting a -/// process remains the responsibility of the injected platform services. struct LanguageTestContext: Equatable, Sendable { let workspaceURL: URL let projectFiles: [URL] @@ -275,9 +312,6 @@ protocol LanguageTestProvider: Sendable { } extension LanguageTestProvider { - /// Context-aware discovery is optional for existing Providers. Providers - /// that need build-system markers can override this without forcing every - /// language implementation to change its public contract at once. func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] { discoverTests(workspaceURL: context.workspaceURL, files: context.projectFiles) } @@ -290,70 +324,6 @@ extension LanguageTestProvider { } } -@MainActor -protocol LanguageServerDocumentSession: LanguageServerSession { - var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } - func synchronizeDocument(url: URL, languageIdentifier: String, text: String) - func closeDocument(url: URL) -} - -@MainActor -protocol LanguageServerNavigationSession: LanguageServerDocumentSession { - func locations( - method: String, - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void - ) -} - -@MainActor -protocol LanguageServerCodeIntelligenceSession: LanguageServerNavigationSession { - func hover( - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result) -> Void - ) - func completions( - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void - ) -} - -@MainActor -protocol LanguageServerEditingSession: LanguageServerCodeIntelligenceSession { - func rename( - documentURL: URL, - position: LanguageServerPosition, - newName: String, - completion: @escaping (Result) -> Void - ) - func formatting( - documentURL: URL, - options: [String: Any], - completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void - ) - func codeActions( - documentURL: URL, - range: LanguageServerRange, - diagnostics: [LanguageServerDiagnostic], - completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void - ) - func execute( - command: LanguageServerCommand, - completion: @escaping (Result) -> Void - ) - func resolveCompletion( - _ item: LanguageServerCompletionItem, - completion: @escaping (Result) -> Void - ) - func resolveCodeAction( - _ action: LanguageServerCodeAction, - completion: @escaping (Result) -> Void - ) -} - @MainActor protocol DebugAdapterSession: AnyObject { var isRunning: Bool { get } @@ -362,9 +332,6 @@ protocol DebugAdapterSession: AnyObject { func stop() } -/// Byte transport used by the language-neutral DAP state machine. Adapters may -/// use a child process' stdio, a TCP socket, or another platform implementation -/// without changing protocol sequencing and inspection behavior. @MainActor protocol DebugAdapterTransport: AnyObject { var isRunning: Bool { get } @@ -376,10 +343,6 @@ protocol DebugAdapterTransport: AnyObject { func stop() } -/// Server-style adapters can ask the client to start a child DAP session (for -/// example a Node process, browser target, worker, or subprocess). The parent -/// transport supplies another connection to the same adapter server without -/// exposing platform sockets to the protocol state machine. @MainActor protocol DebugAdapterChildTransportProviding: AnyObject { func makeChildTransport() -> (any DebugAdapterTransport)? @@ -550,27 +513,15 @@ protocol DebugAdapterControllingSession: DebugAdapterSession { @MainActor protocol LanguageProviderRuntime: AnyObject { var descriptor: LanguageProviderDescriptor { get } - /// Metadata-only indication that this provider exposes the shared editing - /// LSP contract. It must not start or probe a process. - var supportsEditingSession: Bool { get } - /// Metadata-only indication that this runtime has a configured debug - /// adapter factory. Executable discovery still happens lazily on launch. var supportsDebugAdapterSession: Bool { get } - /// Optional actionable guidance when a lazy runtime cannot be created. - /// This keeps installation details in the platform/provider adapter while - /// allowing the shared manager to present a useful error. var unavailableToolingMessage: String? { get } - var declaredLanguageServerFeatures: LanguageServerFeatureSet { get } - func makeLanguageServerSession() -> (any LanguageServerSession)? func makeDebugAdapterSession() -> (any DebugAdapterSession)? func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? } extension LanguageProviderRuntime { - var supportsEditingSession: Bool { false } var supportsDebugAdapterSession: Bool { false } var unavailableToolingMessage: String? { nil } - var declaredLanguageServerFeatures: LanguageServerFeatureSet { [] } func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? { makeDebugAdapterSession() } diff --git a/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/Sources/Lithe/Core/Ports/RuntimeLocator.swift index 7f6358ab..76649973 100644 --- a/Sources/Lithe/Core/Ports/RuntimeLocator.swift +++ b/Sources/Lithe/Core/Ports/RuntimeLocator.swift @@ -1,8 +1,8 @@ import Foundation /// Where a tool candidate came from. The value is intentionally platform -/// neutral so the same run/LSP/DAP UI can explain a Windows registry entry or -/// a macOS Homebrew/Xcode candidate without importing platform frameworks. +/// neutral so the same run/DAP UI can explain a Windows registry entry or a +/// macOS Homebrew/Xcode candidate without importing platform frameworks. enum RuntimeToolSource: String, Codable, Hashable, Sendable { case project case environment @@ -117,5 +117,4 @@ protocol RuntimeLocator: Sendable { func mavenExecutable(forHomePath path: String) -> URL? func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? func systemJDBExecutable() -> URL? - func javaLanguageServerExecutable() -> URL? } diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift new file mode 100644 index 00000000..44ea941d --- /dev/null +++ b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift @@ -0,0 +1,78 @@ +import Foundation +import LitheRustCore + +protocol LanguageProviderCatalogSource: Sendable { + func catalog(workspaceURL: URL?) -> LanguageProviderCatalog +} + +struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { + private struct CatalogPayload: Decodable { + let providers: [ProviderPayload] + } + + private struct ProviderPayload: Decodable { + let id: String + let displayName: String + let fileExtensions: [String] + let fileNames: [String] + let fileNamePrefixes: [String] + let capabilities: [String] + let activationPolicy: ToolingActivationPolicy + let languageId: String? + let languageIdsByExtension: [String: String] + let languageIdsByFileName: [String: String] + + func makeDescriptor() -> LanguageProviderDescriptor { + LanguageProviderDescriptor( + id: id, + displayName: displayName, + fileExtensions: Set(fileExtensions), + fileNames: Set(fileNames), + fileNamePrefixes: Set(fileNamePrefixes), + capabilities: LanguageToolingCapability.names(capabilities), + activationPolicy: activationPolicy, + languageIdentifier: languageId, + languageIdentifiersByExtension: languageIdsByExtension, + languageIdentifiersByFileName: languageIdsByFileName + ) + } + } + + let core: RustCoreBridge + + init(core: RustCoreBridge = RustCoreBridge()) { + self.core = core + } + + func catalog(workspaceURL: URL? = nil) -> LanguageProviderCatalog { + guard let payload = loadPayload(workspaceURL: workspaceURL) else { + return .compatibilityFallback + } + return LanguageProviderCatalog( + descriptors: payload.providers.map { $0.makeDescriptor() } + ) + } + + private func loadPayload(workspaceURL: URL?) -> CatalogPayload? { + guard core.isAvailable else { return nil } + let responsePointer: UnsafeMutablePointer? + if let workspaceURL { + responsePointer = workspaceURL.standardizedFileURL.path.withCString { + lithe_bridge_lsp_provider_catalog_json($0) + } + } else { + responsePointer = lithe_bridge_lsp_provider_catalog_json(nil) + } + guard let responsePointer else { return nil } + defer { lithe_bridge_free_string(responsePointer) } + let response = String(cString: responsePointer) + guard let data = response.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(CatalogPayload.self, from: data) + } +} + +extension LanguageProviderCatalog { + static var standard: Self { + RustLanguageProviderCatalogSource().catalog() + } +} diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 3efc4946..5791b411 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -74,7 +74,9 @@ final class AppModel: ObservableObject, Identifiable { @Published var isProblemsVisible = false @Published var isMavenVisible = false @Published var isDebugVisible = false + @Published var isLSPControlCenterVisible = true @Published var isImplementationChooserVisible = false + @Published private(set) var languageProviderCatalog: LanguageProviderCatalog @Published var languageNavigationProviderID: String? @Published var languageNavigationLocations: [LanguageNavigationLocation] = [] @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions @@ -111,7 +113,6 @@ final class AppModel: ObservableObject, Identifiable { let gitFeature: GitFeatureModel let documentFeature: DocumentFeatureModel let javaFeature: JavaFeatureModel - var languageProviderCatalog: LanguageProviderCatalog { services.languageProviderCatalog } var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations } var languageToolingSessions: LanguageToolingSessionManager { services.languageToolingSessions } var languageTestService: LanguageTestService { services.languageTestService } @@ -153,6 +154,7 @@ final class AppModel: ObservableObject, Identifiable { init(settings: AppSettings, services: AppServices) { self.settings = settings self.services = services + languageProviderCatalog = services.languageProviderCatalog platformUI = services.platformUI workspaceFeature = WorkspaceFeatureModel( operations: services.workspaceOperations, @@ -187,7 +189,6 @@ final class AppModel: ObservableObject, Identifiable { fileOperations: services.fileOperations ) javaFeature = JavaFeatureModel( - service: services.javaLanguageService, markerService: services.javaImplementationMarkerService, operations: services.javaMavenOperations, workspaceOperations: services.workspaceOperations @@ -460,17 +461,39 @@ final class AppModel: ObservableObject, Identifiable { } var languageServerStatusMessage: String { - if isLoadingLanguageNavigation { return "Loading language navigation…" } - if languageNavigationProviderID != nil { return "Language server ready" } + let usesChinese = settings.language == .simplifiedChinese + if isLoadingLanguageNavigation { + return usesChinese ? "正在加载语言导航..." : "Loading language navigation..." + } + if languageNavigationProviderID != nil { + return usesChinese ? "语言服务器已就绪" : "Language server ready" + } if let document = activeDocument, let descriptor = languageProviderCatalog.provider(for: document.url), descriptor.capabilities.contains(.languageServer) { if languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) { - return descriptor.displayName + " language server ready" + return usesChinese + ? "\(descriptor.displayName) 语言服务器已就绪" + : "\(descriptor.displayName) language server ready" } - return descriptor.displayName + " language server available on demand" + return usesChinese + ? "\(descriptor.displayName) 语言服务器可按需启动" + : "\(descriptor.displayName) language server available on demand" + } + return usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" + } + + func restartLanguageServers() { + languageToolingSessions.stopAllLanguageServers() + if let activeDocument { + activateLanguageServerIfAvailable(for: activeDocument) } - return "Open a supported source file" + showNotification(settings.language == .simplifiedChinese ? "语言服务器已重启" : "Language servers restarted") + } + + func clearLanguageServerDiagnostics() { + languageToolingSessions.clearDiagnostics() + showNotification(settings.language == .simplifiedChinese ? "语言服务器诊断已清空" : "Language server diagnostics cleared") } func implementationMarkers( @@ -559,6 +582,7 @@ final class AppModel: ObservableObject, Identifiable { if let previousWorkspaceURL = workspaceURL { workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL) } + reloadLanguageProviderCatalog(for: normalizedURL) stopTerminalSessions() languageTestService.reset() runtimeFeature.openProject(at: normalizedURL) @@ -568,7 +592,6 @@ final class AppModel: ObservableObject, Identifiable { genericDebugFeature.reset() clearLanguageNavigationProjection() javaFeature.stop() - javaFeature.configureProjectRoot(normalizedURL) workspaceFeature.reset() searchFeature.reset() isTerminalVisible = false @@ -616,6 +639,7 @@ final class AppModel: ObservableObject, Identifiable { workspaceFeature.persistWorkspaceSession(for: workspaceURL) } workspaceURL = nil + reloadLanguageProviderCatalog(for: nil) selectedSidebar = .project workspaceFeature.reset() documentFeature.reset() @@ -677,6 +701,12 @@ final class AppModel: ObservableObject, Identifiable { workbenchLayoutStore.save(layout, for: workspaceURL) } + private func reloadLanguageProviderCatalog(for workspaceURL: URL?) { + let catalog = services.languageProviderCatalogSource.catalog(workspaceURL: workspaceURL) + languageProviderCatalog = catalog + languageToolingSessions.updateCatalog(catalog) + } + func openFile( _ url: URL, isReadOnly: Bool = false, diff --git a/Sources/Lithe/Models/ProjectRuntimeModels.swift b/Sources/Lithe/Models/ProjectRuntimeModels.swift index 972bbc88..6592ddf1 100644 --- a/Sources/Lithe/Models/ProjectRuntimeModels.swift +++ b/Sources/Lithe/Models/ProjectRuntimeModels.swift @@ -61,7 +61,6 @@ enum JavaEnvironmentStatus: Equatable, Sendable { case jdkMissing case configuredJDKInvalid(path: String) case jdbMissing - case languageServerMissing var requiresAttention: Bool { self != .checking && self != .ready @@ -70,14 +69,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable { var blocksJavaRun: Bool { switch self { case .jdkMissing, .configuredJDKInvalid, .jdbMissing: true - case .checking, .ready, .languageServerMissing: false - } - } - - var blocksJavaEditing: Bool { - switch self { case .checking, .ready: false - case .jdkMissing, .configuredJDKInvalid, .languageServerMissing, .jdbMissing: true } } } @@ -88,7 +80,6 @@ struct JavaEnvironmentReport: Equatable, Sendable { let javaHomePath: String? let javaExecutablePath: String? let jdbExecutablePath: String? - let languageServerExecutablePath: String? static func checking(for projectURL: URL) -> Self { Self( @@ -96,8 +87,7 @@ struct JavaEnvironmentReport: Equatable, Sendable { projectURL: projectURL.standardizedFileURL, javaHomePath: nil, javaExecutablePath: nil, - jdbExecutablePath: nil, - languageServerExecutablePath: nil + jdbExecutablePath: nil ) } @@ -108,24 +98,21 @@ struct JavaEnvironmentReport: Equatable, Sendable { case .jdkMissing: "JDK not found" case .configuredJDKInvalid: "Configured JDK is invalid" case .jdbMissing: "Java debugger is incomplete" - case .languageServerMissing: "Java language server not found" } } var message: String { switch status { case .checking: - "Lithe is checking the JDK, Java debugger, and Java language server." + "Lithe is checking the JDK and Java debugger." case .ready: - "JDK, JDB, and the Java language server are available for this project." + "JDK and JDB are available for this project." case .jdkMissing: "This project contains Java sources, but no usable JDK was detected." case .configuredJDKInvalid(let path): "The configured JDK path is not a valid JDK: \(path)" case .jdbMissing: "A JDK was found, but its bin/jdb debugger is unavailable." - case .languageServerMissing: - "The JDK is available, but JDT LS is not installed or configured." } } @@ -138,8 +125,6 @@ struct JavaEnvironmentReport: Equatable, Sendable { "Choose another JDK in Project Settings or clear the invalid path." case .jdbMissing: "Use a full JDK distribution instead of a JRE or minimal runtime." - case .languageServerMissing: - "Install/configure jdtls in Project Settings; Java run/debug remains available where the JDK supports it." } } } diff --git a/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift b/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift deleted file mode 100644 index ecef78ae..00000000 --- a/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift +++ /dev/null @@ -1,134 +0,0 @@ -import Foundation - -struct JavaDebugAdapterProcessLaunch: Sendable { - let executableURL: URL - let arguments: [String] - let environment: [String: String] -} - -/// Locates an optional stdio Java DAP adapter without installing or starting -/// it. Java's JDB integration remains the fallback when this adapter is not -/// present, so a clean machine keeps the existing debugging workflow. -struct MacJavaDebugAdapterLocator { - private let environment: [String: String] - private let homeDirectoryURL: URL - private let launchDefinition: StdioDebugAdapterLaunch? - - init( - environment: [String: String], - homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, - launchDefinition: StdioDebugAdapterLaunch? = nil - ) { - self.environment = environment - self.homeDirectoryURL = homeDirectoryURL.standardizedFileURL - self.launchDefinition = launchDefinition - } - - func resolve( - rootURL: URL, - javaExecutableURL: URL? - ) -> JavaDebugAdapterProcessLaunch? { - let configured = environment["LITHE_JAVA_DEBUG_PATH"] - .flatMap { configuredURL($0, relativeTo: rootURL) } - let roots = [ - configured, - rootURL.appendingPathComponent(".lithe/toolchains/java-debug", isDirectory: true), - homeDirectoryURL.appendingPathComponent("Library/Application Support/Lithe/java-debug", isDirectory: true), - homeDirectoryURL.appendingPathComponent(".local/share/lithe/java-debug", isDirectory: true) - ].compactMap { $0 } - - for root in roots { - if let launch = launch(for: root, javaExecutableURL: javaExecutableURL) { - return launch - } - } - - for command in launchDefinition?.executableNames - ?? ["java-debug-adapter", "java-debug", "jdtls-debug"] { - if let executable = executableOnPath(command) { - return JavaDebugAdapterProcessLaunch( - executableURL: executable, - arguments: arguments(for: executable, defaultArguments: launchDefinition?.arguments ?? []), - environment: environment - ) - } - } - return nil - } - - var unavailableMessage: String { - "Java generic debugging requires a stdio Debug Adapter. Set LITHE_JAVA_DEBUG_PATH to the adapter executable or JAR; JDB remains available as the fallback." - } - - private func launch( - for candidate: URL, - javaExecutableURL: URL? - ) -> JavaDebugAdapterProcessLaunch? { - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) else { - return nil - } - if isDirectory.boolValue { - let names = (launchDefinition?.executableNames ?? [ - "java-debug-adapter", - "java-debug", - "jdtls-debug", - "java-debug-adapter.jar", - "java-debug-server.jar" - ]) + ["java-debug-adapter.jar", "java-debug-server.jar"] - for name in names { - if let launch = launch( - for: candidate.appendingPathComponent(name), - javaExecutableURL: javaExecutableURL - ) { - return launch - } - } - return nil - } - - if candidate.pathExtension.lowercased() == "jar" { - guard let javaExecutableURL else { return nil } - return JavaDebugAdapterProcessLaunch( - executableURL: javaExecutableURL, - arguments: arguments( - for: candidate, - defaultArguments: ["-jar", candidate.path] + (launchDefinition?.arguments ?? ["--stdio"]) - ), - environment: environment - ) - } - guard FileManager.default.isExecutableFile(atPath: candidate.path) else { return nil } - return JavaDebugAdapterProcessLaunch( - executableURL: candidate, - arguments: arguments(for: candidate, defaultArguments: launchDefinition?.arguments ?? []), - environment: environment - ) - } - - private func arguments(for _: URL, defaultArguments: [String]) -> [String] { - guard let raw = environment["LITHE_JAVA_DEBUG_ARGS"] else { return defaultArguments } - return RunArgumentParser.parse(raw) - } - - private func executableOnPath(_ command: String) -> URL? { - for directory in (environment["PATH"] ?? "").split(separator: ":") where !directory.isEmpty { - let candidate = URL(fileURLWithPath: String(directory)) - .appendingPathComponent(command) - .standardizedFileURL - if FileManager.default.isExecutableFile(atPath: candidate.path) { - return candidate - } - } - return nil - } - - private func configuredURL(_ value: String, relativeTo rootURL: URL) -> URL? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") { - return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath) - } - return rootURL.appendingPathComponent(trimmed) - } -} diff --git a/Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift b/Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift deleted file mode 100644 index 2fd5b21a..00000000 --- a/Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -struct MacArchiveEntryReader: ArchiveEntryReader { - private let processRunner: any ProcessRunner - - init(processRunner: any ProcessRunner = MacProcessRunner()) { - self.processRunner = processRunner - } - - func read(entry: String, from archive: URL) -> String? { - let result = processRunner.run(ProcessRequest( - executablePath: "/usr/bin/unzip", - arguments: ["-p", archive.path, entry] - )) - guard result.succeeded else { return nil } - return result.output - } -} diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 86be20a7..818d5f52 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -41,61 +41,14 @@ final class MacServiceContainer { toolchainSource: runConfigurationStore, toolDiscovery: MacRuntimeToolDiscovery() ) - let languageProviderCatalog = LanguageProviderCatalog.standard + let languageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) + let languageProviderCatalog = languageProviderCatalogSource.catalog() // Build the catalog once so every standard runtime consumes the // language-pack launch metadata instead of maintaining a second map. let languagePackDefinitions = LanguagePackRegistry.standard( catalog: languageProviderCatalog ) - let languageService = JavaLanguageService( - runtimeService: runtimeService, - process: MacRawProcessSession(), - archiveReader: MacArchiveEntryReader(processRunner: processRunner), - fileStorage: fileStorage, - javaMavenOperations: javaMavenOperations - ) - let javaDebugLaunch = languagePackDefinitions.pack(id: "java")?.debugAdapterLaunch - let javaRuntime = JavaLanguageProviderRuntime( - service: languageService, - catalog: languageProviderCatalog, - debugAdapterAvailability: { - guard let projectURL = runtimeService.projectURL else { return false } - let locator = MacJavaDebugAdapterLocator( - environment: runtimeService.processEnvironment(), - launchDefinition: javaDebugLaunch - ) - return locator.resolve( - rootURL: projectURL, - javaExecutableURL: runtimeService.configuredJavaExecutableURL() - ) != nil - }, - debugAdapterUnavailableMessage: { - MacJavaDebugAdapterLocator( - environment: runtimeService.processEnvironment(), - launchDefinition: javaDebugLaunch - ).unavailableMessage - }, - debugAdapterFactory: { rootURL in - let locator = MacJavaDebugAdapterLocator( - environment: runtimeService.environment(for: .java), - launchDefinition: javaDebugLaunch - ) - guard let launch = locator.resolve( - rootURL: rootURL, - javaExecutableURL: runtimeService.javaExecutableURL() - ) else { return nil } - return DebugAdapterProtocolSession( - adapterID: "java", - executableURL: launch.executableURL, - arguments: launch.arguments, - environment: launch.environment, - process: MacRawProcessSession() - ) - } - ) - let languageToolingRuntimes: [any LanguageProviderRuntime] = [ - javaRuntime - ] + StdioLanguageProviderRuntime.standard( + let languageToolingRuntimes: [any LanguageProviderRuntime] = StdioLanguageProviderRuntime.standard( packs: languagePackDefinitions.packs, runtimeService: runtimeService, processFactory: { MacRawProcessSession() }, @@ -175,9 +128,7 @@ final class MacServiceContainer { javaMavenOperations: javaMavenOperations, runConfigurationOperations: runConfigurationStore ) - let javaImplementationMarkerService = JavaImplementationMarkerService( - languageService: languageService - ) + let javaImplementationMarkerService = JavaImplementationMarkerService() let gitOperations = RustGitOperations(core: rustCore) let workspaceOperations = RustWorkspaceOperations(core: rustCore) let localHistoryOperations = RustLocalHistoryOperations(core: rustCore) @@ -201,6 +152,7 @@ final class MacServiceContainer { credentialResolver: credentialResolver ) services = AppServices( + languageProviderCatalogSource: languageProviderCatalogSource, languageProviderCatalog: languagePackRegistry.catalog, languagePacks: languagePackRegistry, runToolchainRegistry: runToolchainRegistry, @@ -215,7 +167,6 @@ final class MacServiceContainer { fileStorage: fileStorage, fileOperations: fileOperations, projectRuntimeService: runtimeService, - javaLanguageService: languageService, javaImplementationMarkerService: javaImplementationMarkerService, mavenService: mavenService, runService: runService, diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index b1ea9ee8..de6fd4c9 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -25,16 +25,6 @@ enum MacRuntimeDiscovery { .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) } - static func javaLanguageServerExecutable() -> URL? { - [ - "/opt/homebrew/bin/jdtls", - "/usr/local/bin/jdtls", - "/usr/bin/jdtls" - ] - .map(URL.init(fileURLWithPath:)) - .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) - } - static func systemMavenExecutable(environment: [String: String]) -> URL? { discoverMavenExecutables(environment: environment).first } diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift index 808141cf..70c89b84 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift @@ -36,8 +36,4 @@ struct MacRuntimeLocator: RuntimeLocator { func systemJDBExecutable() -> URL? { MacRuntimeDiscovery.systemJDBExecutable() } - - func javaLanguageServerExecutable() -> URL? { - MacRuntimeDiscovery.javaLanguageServerExecutable() - } } diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index a1aa4350..cc506432 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -115,28 +115,28 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { environment: [String: String] ) -> RuntimeToolGuidance { switch command { - case "go", "gopls", "dlv": + case "go", "dlv": return RuntimeToolGuidance( command: command, displayName: "Go toolchain", summary: "Go tooling (\(command)) was not found.", recovery: "Install Go, then install the missing tool with `go install` or add its bin directory to PATH." ) - case "python", "python3", "basedpyright-langserver", "pyright-langserver": + case "python", "python3": return RuntimeToolGuidance( command: command, displayName: "Python toolchain", summary: "Python tooling (\(command)) was not found.", recovery: "Select a Python interpreter or virtual environment, install the provider there, and ensure its bin directory is on PATH." ) - case "node", "npm", "npx", "typescript-language-server", "tsx", "ts-node": + case "node", "npm", "npx", "tsx", "ts-node": return RuntimeToolGuidance( command: command, displayName: "Node.js toolchain", summary: "Node.js tooling (\(command)) was not found.", recovery: "Install Node.js and the required npm package, then restart Lithe or add the Node bin directory to PATH." ) - case "rust-analyzer", "cargo", "rustc", "lldb-dap": + case "cargo", "rustc", "lldb-dap": return RuntimeToolGuidance( command: command, displayName: "Rust/Xcode toolchain", @@ -145,7 +145,7 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { ? "Install or select Xcode Command Line Tools, or configure an lldb-dap path explicitly." : "Install Rust with rustup and ensure Cargo's bin directory is on PATH." ) - case "java-debug-adapter", "java-debug", "jdtls-debug": + case "java-debug-adapter", "java-debug": return RuntimeToolGuidance( command: command, displayName: "Java Debug Adapter", @@ -180,14 +180,10 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { private func homebrewFormula(for command: String) -> String { switch command { - case "gopls": "go" case "dlv": "delve" - case "basedpyright-langserver": "basedpyright" - case "pyright-langserver": "pyright" - case "typescript-language-server": "typescript-language-server" case "tsx": "tsx" case "ts-node": "ts-node" - case "rust-analyzer", "cargo", "rustc": "rust" + case "cargo", "rustc": "rust" case "lldb-dap": "llvm" default: command } diff --git a/Sources/Lithe/Services/JavaImplementationMarkerService.swift b/Sources/Lithe/Services/JavaImplementationMarkerService.swift index 122c49c9..2d34d93b 100644 --- a/Sources/Lithe/Services/JavaImplementationMarkerService.swift +++ b/Sources/Lithe/Services/JavaImplementationMarkerService.swift @@ -1,110 +1,17 @@ import Foundation -/// Resolves Core-produced Java implementation candidates through JDT LS. The -/// editor only draws markers that represent a real implementation relationship. +/// Compatibility boundary for Java implementation markers. The UI call sites +/// remain in place, but marker resolution belongs to the Rust LSP host. @MainActor final class JavaImplementationMarkerService: @unchecked Sendable { - private struct CacheEntry { - let fingerprint: Int - let markers: [JavaImplementationMarker] - } - - private let languageService: JavaLanguageService - private var cache: [URL: CacheEntry] = [:] - - init(languageService: JavaLanguageService) { - self.languageService = languageService - } + init() {} - func invalidate(_ document: EditorDocument) { - cache[document.url.standardizedFileURL] = nil - } + func invalidate(_: EditorDocument) {} func markers( - for document: EditorDocument, - candidates: [JavaImplementationMarker] + for _: EditorDocument, + candidates _: [JavaImplementationMarker] ) async -> [JavaImplementationMarker] { - guard document.url.pathExtension.lowercased() == "java" else { return [] } - - let url = document.url.standardizedFileURL - let fingerprint = document.text.hashValue - if let cached = cache[url], cached.fingerprint == fingerprint { - return cached.markers - } - - let limitedCandidates = Array(candidates.prefix(60)) - guard !limitedCandidates.isEmpty else { - cache[url] = CacheEntry(fingerprint: fingerprint, markers: []) - return [] - } - - var resolved: [JavaImplementationMarker] = [] - // Four requests at a time keeps JDT LS responsive while avoiding a - // burst of requests for large interfaces. - for start in stride(from: 0, to: limitedCandidates.count, by: 4) { - let end = min(start + 4, limitedCandidates.count) - let batch = Array(limitedCandidates[start.. [LanguageNavigationLocation] { - await withCheckedContinuation { continuation in - languageService.locations( - method: "textDocument/implementation", - document: document, - line: candidate.line, - utf16Column: candidate.utf16Column - ) { result in - switch result { - case .success(let locations): continuation.resume(returning: locations) - case .failure: continuation.resume(returning: []) - } - } - } + [] } } diff --git a/Sources/Lithe/Services/JavaLanguageProviderRuntime.swift b/Sources/Lithe/Services/JavaLanguageProviderRuntime.swift deleted file mode 100644 index 02869e8c..00000000 --- a/Sources/Lithe/Services/JavaLanguageProviderRuntime.swift +++ /dev/null @@ -1,304 +0,0 @@ -import Foundation - -/// Bridges the existing JDT LS client into the language-neutral lifecycle. -/// Java navigation keeps its mature protocol implementation while workspace -/// ownership and shutdown move to the shared provider manager. -@MainActor -final class JavaLanguageProviderRuntime: LanguageProviderRuntime { - let descriptor: LanguageProviderDescriptor - private let service: JavaLanguageService - private let debugAdapterFactory: (@MainActor (URL) -> (any DebugAdapterSession)?)? - private let debugAdapterAvailability: @MainActor () -> Bool - private let debugAdapterUnavailableMessage: @MainActor () -> String? - var supportsEditingSession: Bool { true } - var supportsDebugAdapterSession: Bool { - debugAdapterFactory != nil && debugAdapterAvailability() - } - var unavailableToolingMessage: String? { debugAdapterUnavailableMessage() } - var declaredLanguageServerFeatures: LanguageServerFeatureSet { .standardEditing } - - init( - service: JavaLanguageService, - catalog: LanguageProviderCatalog = .standard, - debugAdapterAvailability: @escaping @MainActor () -> Bool = { false }, - debugAdapterUnavailableMessage: @escaping @MainActor () -> String? = { nil }, - debugAdapterFactory: (@MainActor (URL) -> (any DebugAdapterSession)?)? = nil - ) { - precondition( - catalog.descriptors.contains(where: { $0.id == "java" }), - "The language provider catalog must contain Java" - ) - descriptor = catalog.descriptors.first { $0.id == "java" }! - self.service = service - self.debugAdapterFactory = debugAdapterFactory - self.debugAdapterAvailability = debugAdapterAvailability - self.debugAdapterUnavailableMessage = debugAdapterUnavailableMessage - } - - func makeLanguageServerSession() -> (any LanguageServerSession)? { - JavaLanguageServerLifecycleSession(service: service) - } - - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { - debugAdapterFactory?(URL(fileURLWithPath: ".")) - } - - func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? { - debugAdapterFactory?(rootURL.standardizedFileURL) - } -} - -@MainActor -private final class JavaLanguageServerLifecycleSession: LanguageServerEditingSession, LanguageServerFeatureReportingSession { - private let service: JavaLanguageService - private var documents: [URL: EditorDocument] = [:] - var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { - didSet { configureDiagnostics() } - } - private(set) var supportedFeatures: LanguageServerFeatureSet = .standardEditing - var onSupportedFeaturesChange: ((LanguageServerFeatureSet) -> Void)? - - init(service: JavaLanguageService) { - self.service = service - service.onLanguageServerFeatures = { [weak self] features in - self?.supportedFeatures = features - self?.onSupportedFeaturesChange?(features) - } - } - - var isRunning: Bool { service.isStarting || service.isReady } - var isReady: Bool { service.isReady } - - func start(rootURL: URL) throws { - service.configureProjectRoot(rootURL) - service.prepare(for: rootURL) - } - - func stop() { - documents = [:] - service.onLanguageServerDiagnostics = nil - service.onLanguageServerFeatures = nil - service.stop() - } - - func synchronizeDocument(url: URL, languageIdentifier: String, text: String) { - let normalizedURL = url.standardizedFileURL - let document: EditorDocument - if let existing = documents[normalizedURL] { - existing.text = text - document = existing - } else { - document = EditorDocument(url: normalizedURL, text: text, modificationDate: nil) - documents[normalizedURL] = document - } - service.update(document) - } - - func closeDocument(url: URL) { - let normalizedURL = url.standardizedFileURL - guard let document = documents.removeValue(forKey: normalizedURL) else { return } - service.close(document) - } - - func locations( - method: String, - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void - ) { - guard let document = documents[documentURL.standardizedFileURL] else { - completion(.failure(JavaLanguageService.ServiceError.invalidResponse)) - return - } - service.locations( - method: method, - document: document, - line: position.line, - utf16Column: position.utf16Column - ) { result in - completion(result.map { locations in - locations.map { location in - let point = LanguageServerPosition( - line: location.line, - utf16Column: location.utf16Column - ) - return LanguageServerLocation( - url: location.url, - range: LanguageServerRange(start: point, end: point), - isReadOnly: location.isReadOnly, - displayPath: location.displayPath - ) - } - }) - } - } - - func hover( - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result) -> Void - ) { - request( - method: "textDocument/hover", - documentURL: documentURL, - parameters: positionParameters(documentURL, position) - ) { completion($0.map(LanguageServerResponseParser.hover)) } - } - - func completions( - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void - ) { - var parameters = positionParameters(documentURL, position) - parameters["context"] = ["triggerKind": 1] - request(method: "textDocument/completion", documentURL: documentURL, parameters: parameters) { - completion($0.map(LanguageServerResponseParser.completionItems)) - } - } - - func rename( - documentURL: URL, - position: LanguageServerPosition, - newName: String, - completion: @escaping (Result) -> Void - ) { - var parameters = positionParameters(documentURL, position) - parameters["newName"] = newName - request(method: "textDocument/rename", documentURL: documentURL, parameters: parameters) { - completion($0.map(LanguageServerResponseParser.workspaceEdit)) - } - } - - func formatting( - documentURL: URL, - options: [String: Any], - completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void - ) { - request( - method: "textDocument/formatting", - documentURL: documentURL, - parameters: ["textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], "options": options] - ) { completion($0.map(LanguageServerResponseParser.textEdits)) } - } - - func codeActions( - documentURL: URL, - range: LanguageServerRange, - diagnostics: [LanguageServerDiagnostic], - completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void - ) { - let parameters: [String: Any] = [ - "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], - "range": Self.foundationRange(range), - "context": [ - "diagnostics": diagnostics.map(Self.foundationDiagnostic), - "only": ["quickfix", "refactor"] - ] - ] - request(method: "textDocument/codeAction", documentURL: documentURL, parameters: parameters) { - completion($0.map(LanguageServerResponseParser.codeActions)) - } - } - - func execute( - command: LanguageServerCommand, - completion: @escaping (Result) -> Void - ) { - service.executeLanguageServerCommand(command, completion: completion) - } - - func resolveCompletion( - _ item: LanguageServerCompletionItem, - completion: @escaping (Result) -> Void - ) { - service.languageServerRequest( - method: "completionItem/resolve", - parameters: LanguageServerResponseParser.foundationCompletionItem(item) - ) { result in - completion(result.flatMap { value in - guard let item = LanguageServerResponseParser.completionItem(value) else { - return .failure(JavaLanguageService.ServiceError.invalidResponse) - } - return .success(item) - }) - } - } - - func resolveCodeAction( - _ action: LanguageServerCodeAction, - completion: @escaping (Result) -> Void - ) { - service.languageServerRequest( - method: "codeAction/resolve", - parameters: LanguageServerResponseParser.foundationCodeAction(action) - ) { result in - completion(result.flatMap { value in - guard let action = LanguageServerResponseParser.codeAction(value) else { - return .failure(JavaLanguageService.ServiceError.invalidResponse) - } - return .success(action) - }) - } - } - - private func request( - method: String, - documentURL: URL, - parameters: [String: Any], - completion: @escaping (Result) -> Void - ) { - guard let document = documents[documentURL.standardizedFileURL] else { - completion(.failure(JavaLanguageService.ServiceError.invalidResponse)) - return - } - service.languageServerRequest( - method: method, - document: document, - parameters: parameters, - completion: completion - ) - } - - private func positionParameters( - _ documentURL: URL, - _ position: LanguageServerPosition - ) -> [String: Any] { - [ - "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], - "position": ["line": position.line, "character": position.utf16Column] - ] - } - - private func configureDiagnostics() { - service.onLanguageServerDiagnostics = { [weak self] url, diagnostics in - self?.onDiagnostics?(url, diagnostics.map { - LanguageServerDiagnostic( - range: LanguageServerRange( - start: LanguageServerPosition(line: $0.line, utf16Column: $0.utf16Column), - end: LanguageServerPosition(line: $0.endLine, utf16Column: $0.endUTF16Column) - ), - severity: $0.severity.rawValue, - message: $0.message, - source: $0.source, - code: $0.code - ) - }) - } - } - - private static func foundationRange(_ range: LanguageServerRange) -> [String: Any] { - [ - "start": ["line": range.start.line, "character": range.start.utf16Column], - "end": ["line": range.end.line, "character": range.end.utf16Column] - ] - } - - private static func foundationDiagnostic(_ diagnostic: LanguageServerDiagnostic) -> [String: Any] { - var value: [String: Any] = ["range": foundationRange(diagnostic.range), "message": diagnostic.message] - if let severity = diagnostic.severity { value["severity"] = severity } - if let source = diagnostic.source { value["source"] = source } - if let code = diagnostic.code { value["code"] = code } - return value - } -} diff --git a/Sources/Lithe/Services/JavaLanguageService.swift b/Sources/Lithe/Services/JavaLanguageService.swift deleted file mode 100644 index 5eb3cffa..00000000 --- a/Sources/Lithe/Services/JavaLanguageService.swift +++ /dev/null @@ -1,1085 +0,0 @@ -import Foundation - -@MainActor -final class JavaLanguageService: ObservableObject { - enum ServiceError: LocalizedError { - case serverNotInstalled - case serverStopped - case invalidResponse - case capabilityUnavailable(String) - - var errorDescription: String? { - switch self { - case .serverNotInstalled: - "Java language server is not installed. Run: brew install jdtls" - case .serverStopped: - "Java language server stopped unexpectedly" - case .invalidResponse: - "Java language server returned an invalid response" - case .capabilityUnavailable(let method): - "Java language server does not support \(method)" - } - } - } - - @Published private(set) var isStarting = false - @Published private(set) var isReady = false - @Published private(set) var statusMessage = "Java navigation is idle" - @Published private(set) var diagnostics: [URL: [JavaDiagnostic]] = [:] - - var onDiagnostics: ((URL, [JavaDiagnostic]) -> Void)? - var onLanguageServerDiagnostics: ((URL, [JavaDiagnostic]) -> Void)? - var onLanguageServerFeatures: ((LanguageServerFeatureSet) -> Void)? - - private let process: any RawProcessSession - private var readBuffer = Data() - private var nextRequestID = 1 - private var responseHandlers: [Int: (Result) -> Void] = [:] - private var initializedLanguageServerFeatures: LanguageServerFeatureSet = .standardEditing - private var dynamicallyRegisteredLanguageServerFeatures: [String: LanguageServerFeatureSet] = [:] - private var readyHandlers: [(Result) -> Void] = [] - private var openedDocumentVersions: [String: Int] = [:] - private var lastSentDocumentTextByURI: [String: String] = [:] - private var projectURL: URL? - private let runtimeService: ProjectRuntimeService - private let archiveReader: any ArchiveEntryReader - private let fileStorage: any FileStorage - private let javaMavenOperations: any JavaMavenOperations - private var activeOperationID: String? - - init( - runtimeService: ProjectRuntimeService, - process: any RawProcessSession, - archiveReader: any ArchiveEntryReader, - fileStorage: any FileStorage, - javaMavenOperations: any JavaMavenOperations - ) { - self.runtimeService = runtimeService - self.process = process - self.archiveReader = archiveReader - self.fileStorage = fileStorage - self.javaMavenOperations = javaMavenOperations - process.onOutput = { [weak self] data in - Task { @MainActor [weak self] in - self?.receive(data) - } - } - process.onError = { _ in - // JDT LS writes normal JVM diagnostics to stderr. The adapter drains - // the stream without exposing it as navigation status. - } - process.onTermination = { [weak self] _ in - Task { @MainActor [weak self] in - guard let self, self.process.isRunning == false else { return } - self.stop() - } - } - process.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeLifecycle(event) - } - } - } - - func configureProjectRoot(_ url: URL) { - projectURL = url.standardizedFileURL - } - - /// Starts JDT LS in the background while the workspace finishes opening. - func prepare(for rootURL: URL) { - guard !isReady, !isStarting else { return } - ensureReady(for: rootURL) { _ in } - } - - func locations( - method: String, - document: EditorDocument, - line: Int, - utf16Column: Int, - completion: @escaping (Result<[LanguageNavigationLocation], Error>) -> Void - ) { - guard document.url.pathExtension.lowercased() == "java" else { - completion(.failure(ServiceError.invalidResponse)) - return - } - - ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in - guard let self, let document else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success: - self.synchronize(document) - var parameters: [String: Any] = [ - "textDocument": ["uri": document.url.absoluteString], - "position": ["line": line, "character": utf16Column] - ] - if method == "textDocument/references" { - parameters["context"] = ["includeDeclaration": false] - } - self.sendRequest(method: method, parameters: parameters) { response in - switch response { - case .failure(let error): - completion(.failure(error)) - case .success(let value): - let locations = Self.parseLocations(value) - if locations.isEmpty, method == "textDocument/definition" { - self.resolveMissingJDKDefinition( - document: document, - line: line, - utf16Column: utf16Column, - parameters: parameters, - completion: completion - ) - } else { - self.resolveExternalLocations(locations, completion: completion) - } - } - } - } - } - } - - func workspaceSymbols( - query: String, - rootURL: URL, - documents: [EditorDocument], - completion: @escaping (Result<[JavaWorkspaceSymbol], Error>) -> Void - ) { - let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedQuery.isEmpty else { - completion(.success([])) - return - } - - ensureReady(for: rootURL) { [weak self] result in - guard let self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success: - for document in documents where document.url.pathExtension.lowercased() == "java" { - self.synchronize(document) - } - self.sendRequest(method: "workspace/symbol", parameters: [ - "query": normalizedQuery - ]) { response in - switch response { - case .failure(let error): completion(.failure(error)) - case .success(let value): completion(.success(Self.parseWorkspaceSymbols(value))) - } - } - } - } - } - - func inlayHints( - document: EditorDocument, - completion: @escaping (Result<[JavaInlayHint], Error>) -> Void - ) { - guard document.url.pathExtension.lowercased() == "java" else { - completion(.success([])) - return - } - ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in - guard let self, let document else { return } - switch result { - case .failure(let error): completion(.failure(error)) - case .success: - self.synchronize(document) - let lines = max(0, document.text.reduce(0) { $1 == "\n" ? $0 + 1 : $0 }) - self.sendRequest(method: "textDocument/inlayHint", parameters: [ - "textDocument": ["uri": document.url.absoluteString], - "range": [ - "start": ["line": 0, "character": 0], - "end": ["line": lines + 1, "character": 0] - ] - ]) { response in - switch response { - case .failure(let error): completion(.failure(error)) - case .success(let value): completion(.success(Self.parseInlayHints(value))) - } - } - } - } - } - - func update(_ document: EditorDocument) { - guard document.url.pathExtension.lowercased() == "java" else { return } - ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in - guard let self, let document else { return } - guard case .success = result else { return } - self.synchronize(document) - } - } - - func languageServerRequest( - method: String, - document: EditorDocument, - parameters: [String: Any], - completion: @escaping (Result) -> Void - ) { - guard document.url.pathExtension.lowercased() == "java" else { - completion(.failure(ServiceError.invalidResponse)) - return - } - ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in - guard let self, let document else { return } - switch result { - case .failure(let error): completion(.failure(error)) - case .success: - self.synchronize(document) - self.sendRequest(method: method, parameters: parameters, completion: completion) - } - } - } - - func languageServerRequest( - method: String, - parameters: [String: Any], - completion: @escaping (Result) -> Void - ) { - ensureReady(for: projectURL ?? fileStorage.homeDirectory()) { [weak self] result in - guard let self else { return } - switch result { - case .failure(let error): completion(.failure(error)) - case .success: self.sendRequest(method: method, parameters: parameters, completion: completion) - } - } - } - - func executeLanguageServerCommand( - _ command: LanguageServerCommand, - completion: @escaping (Result) -> Void - ) { - executeCommand( - command: command.command, - arguments: command.arguments.map(\.foundationObject) - ) { result in - completion(result.map { _ in () }) - } - } - - func close(_ document: EditorDocument) { - guard document.url.pathExtension.lowercased() == "java" else { return } - let uri = document.url.absoluteString - if openedDocumentVersions[uri] != nil, isReady, !document.isReadOnly { - sendNotification(method: "textDocument/didClose", parameters: [ - "textDocument": ["uri": uri] - ]) - } - openedDocumentVersions[uri] = nil - lastSentDocumentTextByURI[uri] = nil - diagnostics[document.url.standardizedFileURL] = nil - onDiagnostics?(document.url.standardizedFileURL, []) - onLanguageServerDiagnostics?(document.url.standardizedFileURL, []) - } - - func stop() { - process.stop() - let error = ServiceError.serverStopped - for handler in responseHandlers.values { - handler(.failure(error)) - } - for handler in readyHandlers { - handler(.failure(error)) - } - responseHandlers = [:] - readyHandlers = [] - openedDocumentVersions = [:] - lastSentDocumentTextByURI = [:] - diagnostics = [:] - initializedLanguageServerFeatures = .standardEditing - dynamicallyRegisteredLanguageServerFeatures = [:] - readBuffer = Data() - isStarting = false - isReady = false - statusMessage = "Java navigation is idle" - activeOperationID = nil - } - - private func ensureReady( - for fileDirectory: URL, - completion: @escaping (Result) -> Void - ) { - if isReady { - completion(.success(())) - return - } - readyHandlers.append(completion) - guard !isStarting else { return } - - guard let executableURL = runtimeService.javaLanguageServerExecutable() else { - finishStartup(.failure(ServiceError.serverNotInstalled)) - return - } - - let root = projectRoot(containing: fileDirectory) - projectURL = root - isStarting = true - statusMessage = "Starting Java language server..." - - var arguments: [String] = [] - if let javaExecutable = runtimeService.javaExecutableURL() { - arguments.append(contentsOf: ["--java-executable", javaExecutable.path]) - } - // jdtls defaults to a 1 GiB initial heap. Keep the on-demand language - // service lightweight for ordinary projects while leaving room for - // larger workspaces to grow when needed. - arguments.append(contentsOf: [ - "--jvm-arg=-Xms256m", - "--jvm-arg=-Xmx1024m" - ]) - let dataDirectory = dataDirectory(for: root) - arguments.append(contentsOf: ["-data", dataDirectory.path]) - let operationID = UUID().uuidString - activeOperationID = operationID - do { - try fileStorage.createDirectory(at: dataDirectory, withIntermediateDirectories: true) - try process.start(ProcessRequest( - operationID: operationID, - executablePath: executableURL.path, - arguments: arguments, - workingDirectory: root.path, - keepsStandardInputOpen: true - )) - initialize(root: root) - } catch { - finishStartup(.failure(error)) - } - } - - private func consumeLifecycle(_ event: ProcessLifecycleEvent) { - guard event.operationID == activeOperationID else { return } - switch event.state { - case .starting: - isStarting = true - case .running: - isStarting = true - case .stopping: - statusMessage = event.message ?? "Stopping Java language server..." - case .finished: - break - case .failed: - isStarting = false - isReady = false - statusMessage = event.message ?? "Java language server failed to start" - } - } - - private func initialize(root: URL) { - let capabilities: [String: Any] = [ - "textDocument": [ - "definition": ["dynamicRegistration": true, "linkSupport": true], - "references": ["dynamicRegistration": true], - "implementation": ["dynamicRegistration": true, "linkSupport": true], - "hover": ["dynamicRegistration": true, "contentFormat": ["markdown", "plaintext"]], - "completion": ["dynamicRegistration": true, "completionItem": [ - "documentationFormat": ["markdown", "plaintext"], - "snippetSupport": true, - "resolveSupport": ["properties": ["detail", "documentation", "textEdit", "additionalTextEdits"]] - ]], - "rename": ["dynamicRegistration": true, "prepareSupport": true], - "formatting": ["dynamicRegistration": true], - "codeAction": [ - "dynamicRegistration": true, - "resolveSupport": ["properties": ["edit", "command"]], - "codeActionLiteralSupport": [ - "codeActionKind": ["valueSet": ["quickfix", "refactor", "source"]] - ] - ], - "inlayHint": ["dynamicRegistration": false, "resolveSupport": ["properties": []]], - "publishDiagnostics": ["relatedInformation": true], - "synchronization": ["dynamicRegistration": false, "didSave": true] - ], - "workspace": [ - "workspaceFolders": true, - "configuration": true, - "applyEdit": true, - "executeCommand": ["dynamicRegistration": true], - "symbol": ["dynamicRegistration": false] - ], - "window": ["workDoneProgress": true] - ] - let parameters: [String: Any] = [ - "processId": ProcessInfo.processInfo.processIdentifier, - "clientInfo": ["name": "Lithe", "version": "0.1.0"], - "rootUri": root.absoluteString, - "capabilities": capabilities, - "workspaceFolders": [["uri": root.absoluteString, "name": root.lastPathComponent]] - ] - sendRequest(method: "initialize", parameters: parameters) { [weak self] result in - guard let self else { return } - switch result { - case .failure(let error): - self.finishStartup(.failure(error)) - case .success(let value): - self.initializedLanguageServerFeatures = LanguageServerResponseParser.serverFeatures( - fromInitializeResult: value - ) - self.dynamicallyRegisteredLanguageServerFeatures = [:] - self.publishLanguageServerFeatures() - self.sendNotification(method: "initialized", parameters: [:]) - self.sendNotification(method: "workspace/didChangeConfiguration", parameters: [ - "settings": [ - "java": [ - "inlayHints": [ - "parameterNames": ["enabled": "all"] - ] - ] - ] - ]) - self.isReady = true - self.isStarting = false - self.statusMessage = "Java navigation ready" - self.finishReadyHandlers(.success(())) - } - } - } - - private func synchronize(_ document: EditorDocument) { - let uri = document.url.absoluteString - if let version = openedDocumentVersions[uri] { - guard lastSentDocumentTextByURI[uri] != document.text else { return } - let nextVersion = version + 1 - openedDocumentVersions[uri] = nextVersion - lastSentDocumentTextByURI[uri] = document.text - sendNotification(method: "textDocument/didChange", parameters: [ - "textDocument": ["uri": uri, "version": nextVersion], - "contentChanges": [["text": document.text]] - ]) - } else { - openedDocumentVersions[uri] = 1 - lastSentDocumentTextByURI[uri] = document.text - sendNotification(method: "textDocument/didOpen", parameters: [ - "textDocument": [ - "uri": uri, - "languageId": "java", - "version": 1, - "text": document.text - ] - ]) - } - } - - private func sendRequest( - method: String, - parameters: [String: Any], - completion: @escaping (Result) -> Void - ) { - let requiredFeature = LanguageServerResponseParser.requiredFeature(forRequestMethod: method) - let supportedFeatures = dynamicallyRegisteredLanguageServerFeatures.values.reduce( - initializedLanguageServerFeatures - ) { $0.union($1) } - guard requiredFeature.isEmpty || supportedFeatures.contains(requiredFeature) else { - completion(.failure(ServiceError.capabilityUnavailable(method))) - return - } - let id = nextRequestID - nextRequestID += 1 - responseHandlers[id] = completion - send(["jsonrpc": "2.0", "id": id, "method": method, "params": parameters]) - } - - private func executeCommand( - command: String, - arguments: [Any], - completion: @escaping (Result) -> Void - ) { - ensureReady(for: projectURL ?? fileStorage.homeDirectory()) { [weak self] result in - guard let self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success: - self.sendRequest(method: "workspace/executeCommand", parameters: [ - "command": command, - "arguments": arguments - ], completion: completion) - } - } - } - - private func sendNotification(method: String, parameters: [String: Any]) { - send(["jsonrpc": "2.0", "method": method, "params": parameters]) - } - - private func sendResponse(id: Any, result: Any) { - send(["jsonrpc": "2.0", "id": id, "result": result]) - } - - private func send(_ message: [String: Any]) { - guard JSONSerialization.isValidJSONObject(message), - let body = try? JSONSerialization.data(withJSONObject: message) else { return } - var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8) - framed.append(body) - try? process.send(framed) - } - - private func receive(_ data: Data) { - readBuffer.append(data) - while let headerEnd = readBuffer.range(of: Data("\r\n\r\n".utf8)) { - let headerData = readBuffer[..= bodyStart + contentLength else { return } - let body = readBuffer.subdata(in: bodyStart..<(bodyStart + contentLength)) - readBuffer.removeSubrange(0..<(bodyStart + contentLength)) - if let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any] { - handle(object) - } - } - } - - private func handle(_ message: [String: Any]) { - if let id = message["id"] as? Int, message["method"] == nil { - guard let handler = responseHandlers.removeValue(forKey: id) else { return } - if let error = message["error"] as? [String: Any] { - let detail = error["message"] as? String ?? ServiceError.invalidResponse.localizedDescription - handler(.failure(NSError(domain: "JavaLanguageService", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]))) - } else { - handler(.success(message["result"] ?? NSNull())) - } - return - } - - guard let method = message["method"] as? String else { return } - if message["id"] == nil { - handleNotification(method: method, parameters: message["params"] as? [String: Any]) - return - } - guard let id = message["id"] else { return } - switch method { - case "client/registerCapability": - registerCapabilities(message["params"] as? [String: Any]) - sendResponse(id: id, result: NSNull()) - case "client/unregisterCapability": - unregisterCapabilities(message["params"] as? [String: Any]) - sendResponse(id: id, result: NSNull()) - case "workspace/configuration": - let items = ((message["params"] as? [String: Any])?["items"] as? [[String: Any]]) ?? [] - sendResponse(id: id, result: items.map { item -> Any in - switch item["section"] as? String { - case "java": - return ["inlayHints": ["parameterNames": ["enabled": "all"]]] - case "java.inlayHints": - return ["parameterNames": ["enabled": "all"]] - case "java.inlayHints.parameterNames": - return ["enabled": "all"] - case "java.inlayHints.parameterNames.enabled": - return "all" - default: - return NSNull() - } - }) - default: - sendResponse(id: id, result: NSNull()) - } - } - - private func registerCapabilities(_ parameters: [String: Any]?) { - let registrations = parameters?["registrations"] as? [[String: Any]] ?? [] - for registration in registrations { - guard let id = registration["id"] as? String, - let method = registration["method"] as? String else { continue } - dynamicallyRegisteredLanguageServerFeatures[id] = LanguageServerResponseParser.registeredFeatures( - for: method, - registerOptions: registration["registerOptions"] - ) - } - publishLanguageServerFeatures() - } - - private func unregisterCapabilities(_ parameters: [String: Any]?) { - let registrations = (parameters?["unregisterations"] as? [[String: Any]]) - ?? (parameters?["unregistrations"] as? [[String: Any]]) - ?? [] - for registration in registrations { - guard let id = registration["id"] as? String else { continue } - dynamicallyRegisteredLanguageServerFeatures[id] = nil - } - publishLanguageServerFeatures() - } - - private func publishLanguageServerFeatures() { - let features = dynamicallyRegisteredLanguageServerFeatures.values.reduce( - initializedLanguageServerFeatures - ) { $0.union($1) } - onLanguageServerFeatures?(features) - } - - private func handleNotification(method: String, parameters: [String: Any]?) { - guard method == "textDocument/publishDiagnostics", - let parameters, - let uri = parameters["uri"] as? String, - let url = URL(string: uri) else { return } - let normalizedURL = url.standardizedFileURL - let parsed = Self.parseDiagnostics( - parameters["diagnostics"] as? [[String: Any]] ?? [], - fileURL: normalizedURL - ) - diagnostics[normalizedURL] = parsed - onDiagnostics?(normalizedURL, parsed) - onLanguageServerDiagnostics?(normalizedURL, parsed) - } - - private func finishStartup(_ result: Result) { - isStarting = false - if case .failure(let error) = result { - statusMessage = error.localizedDescription - } - finishReadyHandlers(result) - } - - private func finishReadyHandlers(_ result: Result) { - let handlers = readyHandlers - readyHandlers = [] - handlers.forEach { $0(result) } - } - - private func projectRoot(containing directory: URL) -> URL { - var current = directory.standardizedFileURL - while current.path != "/" { - if fileStorage.fileExists(at: current.appendingPathComponent("pom.xml")) || - fileStorage.fileExists(at: current.appendingPathComponent("build.gradle")) || - fileStorage.fileExists(at: current.appendingPathComponent("build.gradle.kts")) || - fileStorage.fileExists(at: current.appendingPathComponent(".git")) { - return current - } - current.deleteLastPathComponent() - } - return projectURL ?? directory - } - - private func dataDirectory(for root: URL) -> URL { - let key = String(root.path.utf8.reduce(UInt64(5381)) { ($0 &* 33) &+ UInt64($1) }, radix: 16) - return fileStorage.cacheDirectory() - .appendingPathComponent("Lithe/jdtls", isDirectory: true) - .appendingPathComponent(key, isDirectory: true) - } - - private func resolveExternalLocations( - _ locations: [LanguageNavigationLocation], - completion: @escaping (Result<[LanguageNavigationLocation], Error>) -> Void - ) { - func resolveNext( - at index: Int, - resolved: [LanguageNavigationLocation] - ) { - guard index < locations.count else { - completion(.success(resolved)) - return - } - - let location = locations[index] - guard location.url.scheme?.lowercased() != "file" else { - resolveNext(at: index + 1, resolved: resolved + [location]) - return - } - - if let source = jdkSource(for: location.url), - let sourceURL = materializeLibrarySource(source, for: location.url) { - let materialized = LanguageNavigationLocation( - url: sourceURL, - line: location.line, - utf16Column: location.utf16Column, - isReadOnly: true, - displayPath: Self.displayPath(for: location.url) - ) - resolveNext(at: index + 1, resolved: resolved + [materialized]) - return - } - - executeCommand(command: "java.decompile", arguments: [location.url.absoluteString]) { result in - switch result { - case .success(let value) where value is String: - let content = value as! String - if let sourceURL = self.materializeLibrarySource(content, for: location.url) { - let materialized = LanguageNavigationLocation( - url: sourceURL, - line: location.line, - utf16Column: location.utf16Column, - isReadOnly: true, - displayPath: Self.displayPath(for: location.url) - ) - resolveNext(at: index + 1, resolved: resolved + [materialized]) - } else { - resolveNext(at: index + 1, resolved: resolved) - } - case .success, .failure: - resolveNext(at: index + 1, resolved: resolved) - } - } - } - - resolveNext(at: 0, resolved: []) - } - - private func resolveMissingJDKDefinition( - document: EditorDocument, - line: Int, - utf16Column: Int, - parameters: [String: Any], - completion: @escaping (Result<[LanguageNavigationLocation], Error>) -> Void - ) { - let finish: (String?) -> Void = { qualifiedName in - guard let qualifiedName, - let symbol = Self.identifier(at: line, utf16Column: utf16Column, in: document.text), - let location = self.jdkDefinitionLocation( - for: qualifiedName, - symbol: symbol - ) else { - completion(.success([])) - return - } - completion(.success([location])) - } - - executeCommand(command: "java.getFullyQualifiedName", arguments: [parameters]) { [weak self] result in - guard let self else { return } - if case .success(let value) = result, - let qualifiedName = value as? String, - !qualifiedName.isEmpty { - finish(qualifiedName) - return - } - - self.sendRequest(method: "textDocument/hover", parameters: parameters) { hoverResult in - switch hoverResult { - case .success(let value): - finish(Self.qualifiedName(fromHover: value, symbol: Self.identifier( - at: line, - utf16Column: utf16Column, - in: document.text - ))) - case .failure: - completion(.success([])) - } - } - } - } - - private static func parseLocations(_ value: Any) -> [LanguageNavigationLocation] { - let rawLocations: [[String: Any]] - if let array = value as? [[String: Any]] { - rawLocations = array - } else if let object = value as? [String: Any] { - rawLocations = [object] - } else { - return [] - } - - return rawLocations.compactMap { object in - let uri = (object["uri"] as? String) ?? (object["targetUri"] as? String) - let range = (object["range"] as? [String: Any]) ?? - (object["targetSelectionRange"] as? [String: Any]) ?? - (object["targetRange"] as? [String: Any]) - let start = range?["start"] as? [String: Any] - guard let uri, let url = URL(string: uri), - let line = start?["line"] as? Int, - let column = start?["character"] as? Int else { return nil } - return LanguageNavigationLocation(url: url, line: line, utf16Column: column) - } - } - - private func javaHomeURL() -> URL? { - guard let javaExecutable = runtimeService.javaExecutableURL() else { return nil } - return javaExecutable - .deletingLastPathComponent() - .deletingLastPathComponent() - .resolvingSymlinksInPath() - } - - private func jdkSource(for uri: URL) -> String? { - guard uri.scheme?.lowercased() == "jdt", - let entry = Self.jdkSourceEntry(for: uri), - let javaHome = javaHomeURL() else { return nil } - - let archives = [ - javaHome.appendingPathComponent("lib/src.zip"), - javaHome.appendingPathComponent("src.zip") - ] - let entries = [entry, entry.hasPrefix("java.base/") ? String(entry.dropFirst("java.base/".count)) : "java.base/\(entry)"] - for archive in archives where fileStorage.fileExists(at: archive) { - for candidate in entries where !candidate.isEmpty { - if let source = readZipEntry(candidate, from: archive), !source.isEmpty { - return source - } - } - } - return nil - } - - private static func jdkSourceEntry(for uri: URL) -> String? { - let components = uri.path - .split(separator: "/") - .map(String.init) - guard let last = components.last, - last.hasSuffix(".class") else { return nil } - var sourceComponents = components - sourceComponents[sourceComponents.count - 1] = String(last.dropLast(".class".count)) + ".java" - return sourceComponents.joined(separator: "/") - } - - private func readZipEntry(_ entry: String, from archive: URL) -> String? { - archiveReader.read(entry: entry, from: archive) - } - - private func materializeLibrarySource(_ content: String, for uri: URL) -> URL? { - guard !content.isEmpty else { return nil } - let key = String(uri.absoluteString.utf8.reduce(UInt64(5381)) { ($0 &* 33) &+ UInt64($1) }, radix: 16) - let baseName = uri.deletingPathExtension().lastPathComponent - .replacingOccurrences(of: "[^A-Za-z0-9_$-]", with: "_", options: .regularExpression) - let fileName = "\(baseName.isEmpty ? "JavaLibrary" : baseName)-\(key).java" - let directory = fileStorage.cacheDirectory() - .appendingPathComponent("Lithe/java-sources", isDirectory: true) - let destination = directory.appendingPathComponent(fileName) - do { - try fileStorage.createDirectory(at: directory, withIntermediateDirectories: true) - try fileStorage.writeData(Data(content.utf8), to: destination, options: []) - return destination - } catch { - return nil - } - } - - private static func displayPath(for uri: URL) -> String { - let components = uri.path.split(separator: "/").map(String.init) - guard let last = components.last else { return uri.lastPathComponent } - var displayComponents = components - if last.hasSuffix(".class") { - displayComponents[displayComponents.count - 1] = String(last.dropLast(".class".count)) + ".java" - } - return displayComponents.joined(separator: "/") - } - - private static func identifier(at line: Int, utf16Column: Int, in text: String) -> String? { - let lines = text.components(separatedBy: .newlines) - guard lines.indices.contains(line) else { return nil } - let units = Array(lines[line].utf16) - guard !units.isEmpty else { return nil } - var index = min(max(0, utf16Column), units.count - 1) - if !isJavaIdentifierUnit(units[index]), index > 0, - isJavaIdentifierUnit(units[index - 1]) { - index -= 1 - } - guard isJavaIdentifierUnit(units[index]) else { return nil } - var start = index - while start > 0, isJavaIdentifierUnit(units[start - 1]) { start -= 1 } - var end = index + 1 - while end < units.count, isJavaIdentifierUnit(units[end]) { end += 1 } - return String(decoding: units[start.. Bool { - (unit >= 48 && unit <= 57) || - (unit >= 65 && unit <= 90) || - (unit >= 97 && unit <= 122) || - unit == 95 || unit == 36 - } - - private static func qualifiedName(fromHover value: Any, symbol: String?) -> String? { - guard let object = value as? [String: Any], - let contents = object["contents"] as? [Any] else { return nil } - let text = contents.compactMap { item -> String? in - if let string = item as? String { return string } - return (item as? [String: Any])?["value"] as? String - }.joined(separator: "\n") - guard !text.isEmpty, - let expression = try? NSRegularExpression( - pattern: "\\b(?:java|javax|jdk|sun)\\.[A-Za-z0-9_$.]+" - ) else { return nil } - let fullRange = NSRange(location: 0, length: (text as NSString).length) - let matches = expression.matches(in: text, range: fullRange).compactMap { - Range($0.range, in: text).map { String(text[$0]) } - } - guard !matches.isEmpty else { return nil } - if let symbol { - if let matching = matches.last(where: { $0.split(separator: ".").last.map(String.init) == symbol }) { - return matching - } - } - return matches.first - } - - private func jdkDefinitionLocation( - for qualifiedName: String, - symbol: String - ) -> LanguageNavigationLocation? { - let parts = qualifiedName.split(separator: ".").map(String.init) - guard parts.count >= 2, - ["java", "javax", "jdk", "sun"].contains(parts[0]) else { return nil } - - guard let typeIndex = parts.firstIndex(where: { part in - guard let first = part.first else { return false } - return first.isUppercase || part.contains("$") - }), typeIndex > 0 else { return nil } - - let packageParts = Array(parts[.. [JavaWorkspaceSymbol] { - guard let objects = value as? [[String: Any]] else { return [] } - return objects.compactMap { object in - guard let name = object["name"] as? String, - let kind = object["kind"] as? Int, - let location = object["location"] as? [String: Any] else { return nil } - let uri = (location["uri"] as? String) ?? (location["targetUri"] as? String) - let range = (location["range"] as? [String: Any]) ?? - (location["targetSelectionRange"] as? [String: Any]) - let start = range?["start"] as? [String: Any] - guard let uri, - let url = URL(string: uri), - let line = start?["line"] as? Int, - let column = start?["character"] as? Int else { return nil } - return JavaWorkspaceSymbol( - name: name, - containerName: object["containerName"] as? String, - url: url.standardizedFileURL, - line: line, - utf16Column: column, - kind: kind - ) - } - } - - private static func parseInlayHints(_ value: Any) -> [JavaInlayHint] { - guard let objects = value as? [[String: Any]] else { return [] } - return objects.compactMap { object in - guard let position = object["position"] as? [String: Any], - let line = position["line"] as? Int, - let column = position["character"] as? Int else { return nil } - let label: String - if let raw = object["label"] as? String { - label = raw - } else if let parts = object["label"] as? [[String: Any]] { - label = parts.compactMap { $0["value"] as? String }.joined() - } else { - return nil - } - guard !label.isEmpty else { return nil } - return JavaInlayHint(line: line, utf16Column: column, label: label) - } - } - - private static func parseDiagnostics( - _ objects: [[String: Any]], - fileURL: URL - ) -> [JavaDiagnostic] { - objects.compactMap { parseDiagnostic($0, fileURL: fileURL) } - } - - private static func parseDiagnostic( - _ object: [String: Any], - fileURL: URL - ) -> JavaDiagnostic? { - guard let range = object["range"] as? [String: Any], - let start = range["start"] as? [String: Any], - let line = start["line"] as? Int, - let column = start["character"] as? Int, - let message = object["message"] as? String, - !message.isEmpty else { return nil } - let end = range["end"] as? [String: Any] - let endLine = end?["line"] as? Int ?? line - let endColumn = end?["character"] as? Int ?? column + 1 - let severity = JavaDiagnosticSeverity(rawValue: object["severity"] as? Int ?? 1) ?? .error - let source = object["source"] as? String - let code = Self.parseDiagnosticCode(object["code"]) - let tags = Self.parseDiagnosticTags(object["tags"]) - let relatedInformation = Self.parseRelatedInformation(object["relatedInformation"]) - let id = fileURL.path + ":" + String(line) + ":" + String(column) + ":" + message + ":" + (code ?? "") - return JavaDiagnostic( - id: id, - fileURL: fileURL, - line: max(0, line), - utf16Column: max(0, column), - endLine: max(0, endLine), - endUTF16Column: max(0, endColumn), - severity: severity, - message: message, - source: source, - code: code, - tags: tags, - relatedInformation: relatedInformation - ) - } - - private static func parseDiagnosticCode(_ value: Any?) -> String? { - if let value = value as? String, !value.isEmpty { return value } - if let value = value as? Int { return String(value) } - return nil - } - - private static func parseDiagnosticTags(_ value: Any?) -> Set { - guard let values = value as? [Any] else { return [] } - return Set(values.compactMap { value in - guard let rawValue = value as? Int else { return nil } - return JavaDiagnosticTag(rawValue: rawValue) - }) - } - - private static func parseRelatedInformation(_ value: Any?) -> [JavaDiagnosticRelatedInformation] { - guard let objects = value as? [[String: Any]] else { return [] } - return objects.compactMap { object in - guard let location = object["location"] as? [String: Any], - let uri = location["uri"] as? String, - let fileURL = URL(string: uri), - let range = location["range"] as? [String: Any], - let start = range["start"] as? [String: Any], - let line = start["line"] as? Int, - let column = start["character"] as? Int, - let message = object["message"] as? String, - !message.isEmpty else { return nil } - return JavaDiagnosticRelatedInformation( - fileURL: fileURL.standardizedFileURL, - line: max(0, line), - utf16Column: max(0, column), - message: message - ) - } - } -} diff --git a/Sources/Lithe/Services/LanguagePackRegistry.swift b/Sources/Lithe/Services/LanguagePackRegistry.swift index 13d87632..f6c23816 100644 --- a/Sources/Lithe/Services/LanguagePackRegistry.swift +++ b/Sources/Lithe/Services/LanguagePackRegistry.swift @@ -4,8 +4,8 @@ import Foundation /// /// Existing registries remain available as focused implementation details, but /// the application composition root should create this registry once and pass -/// its derived views to Run, LSP/DAP, and Test services. Adding a language then -/// means adding one pack instead of editing every service initializer. +/// its derived views to Run, Debug, and Test services. LSP metadata is resolved +/// by the Rust language-server host, not by Swift language packs. @MainActor final class LanguagePackRegistry { let packs: [LanguagePack] @@ -65,16 +65,13 @@ final class LanguagePackRegistry { let testProviders: [any LanguageTestProvider] = descriptor.capabilities.contains(.testing) ? [StandardLanguageTestProvider(descriptor: descriptor)] : [] - let tooling = Self.standardToolingDefinition(for: descriptor.id) - return LanguagePack( descriptor: descriptor, runProvider: runProvider, toolchainProviders: standardToolchains.filter { $0.languageProviderID == descriptor.id }, - languageServerLaunch: tooling.languageServer, - debugAdapterLaunch: tooling.debugAdapter, + debugAdapterLaunch: Self.standardDebugAdapterDefinition(for: descriptor.id), toolingRuntime: runtimeByID[descriptor.id], testProviders: testProviders ) @@ -82,80 +79,45 @@ final class LanguagePackRegistry { return Self(packs: packs) } - private struct StandardToolingDefinition { - let languageServer: StdioLanguageServerLaunch? - let debugAdapter: StdioDebugAdapterLaunch? - } - - /// The standard catalog is intentionally assembled as complete packs. - /// Runtime implementations consume these definitions but never duplicate - /// language-name-to-executable maps of their own. - private static func standardToolingDefinition(for id: String) -> StandardToolingDefinition { + private static func standardDebugAdapterDefinition(for id: String) -> StdioDebugAdapterLaunch? { switch id { case "java": - return StandardToolingDefinition( - languageServer: nil, - debugAdapter: StdioDebugAdapterLaunch( - adapterID: "java", - executableNames: ["java-debug-adapter", "java-debug", "jdtls-debug"], - arguments: ["--stdio"] - ) + return StdioDebugAdapterLaunch( + adapterID: "java", + executableNames: ["java-debug-adapter", "java-debug"], + arguments: ["--stdio"] ) case "go": - return StandardToolingDefinition( - languageServer: StdioLanguageServerLaunch( - executableNames: ["gopls"], - arguments: [] - ), - debugAdapter: StdioDebugAdapterLaunch( - adapterID: "go", - executableNames: ["dlv"], - arguments: ["dap"] - ) + return StdioDebugAdapterLaunch( + adapterID: "go", + executableNames: ["dlv"], + arguments: ["dap"] ) case "python": - return StandardToolingDefinition( - languageServer: StdioLanguageServerLaunch( - executableNames: ["basedpyright-langserver", "pyright-langserver"], - arguments: ["--stdio"] - ), - debugAdapter: StdioDebugAdapterLaunch( - adapterID: "python", - executableNames: ["python3", "python"], - arguments: ["-m", "debugpy.adapter"] - ) + return StdioDebugAdapterLaunch( + adapterID: "python", + executableNames: ["python3", "python"], + arguments: ["-m", "debugpy.adapter"] ) case "node": - return StandardToolingDefinition( - languageServer: StdioLanguageServerLaunch( - executableNames: ["typescript-language-server"], - arguments: ["--stdio"] - ), - debugAdapter: StdioDebugAdapterLaunch( - adapterID: "pwa-node", - executableNames: ["js-debug-dap"], - arguments: [] - ) + return StdioDebugAdapterLaunch( + adapterID: "pwa-node", + executableNames: ["js-debug-dap"], + arguments: [] ) case "rust": - return StandardToolingDefinition( - languageServer: StdioLanguageServerLaunch( - executableNames: ["rust-analyzer"], - arguments: [] - ), - debugAdapter: StdioDebugAdapterLaunch( - adapterID: "lldb", - executableNames: ["lldb-dap"], - arguments: [], - fallbacks: [ - // Xcode exposes lldb-dap through xcrun even when the - // app's inherited PATH does not contain the tool. - .init(executableName: "xcrun", argumentPrefix: ["lldb-dap"]) - ] - ) + return StdioDebugAdapterLaunch( + adapterID: "lldb", + executableNames: ["lldb-dap"], + arguments: [], + fallbacks: [ + // Xcode exposes lldb-dap through xcrun even when the + // app's inherited PATH does not contain the tool. + .init(executableName: "xcrun", argumentPrefix: ["lldb-dap"]) + ] ) default: - return StandardToolingDefinition(languageServer: nil, debugAdapter: nil) + return nil } } } diff --git a/Sources/Lithe/Services/LanguageServerResponseParser.swift b/Sources/Lithe/Services/LanguageServerResponseParser.swift deleted file mode 100644 index ad203b29..00000000 --- a/Sources/Lithe/Services/LanguageServerResponseParser.swift +++ /dev/null @@ -1,287 +0,0 @@ -import Foundation - -enum LanguageServerResponseParser { - static func requiredFeature(forRequestMethod method: String) -> LanguageServerFeatureSet { - switch method { - case "textDocument/definition": .definition - case "textDocument/references": .references - case "textDocument/implementation": .implementation - case "textDocument/hover": .hover - case "textDocument/completion": .completion - case "completionItem/resolve": .completionResolve - case "textDocument/rename": .rename - case "textDocument/formatting": .formatting - case "textDocument/codeAction": .codeActions - case "codeAction/resolve": .codeActionResolve - case "workspace/executeCommand": .executeCommand - default: [] - } - } - - static func registeredFeatures( - for method: String, - registerOptions: Any? = nil - ) -> LanguageServerFeatureSet { - let options = registerOptions as? [String: Any] - switch method { - case "textDocument/definition": - return .definition - case "textDocument/references": - return .references - case "textDocument/implementation": - return .implementation - case "textDocument/hover": - return .hover - case "textDocument/completion": - var features: LanguageServerFeatureSet = .completion - if options?["resolveProvider"] as? Bool == true { - features.insert(.completionResolve) - } - return features - case "textDocument/rename": - return .rename - case "textDocument/formatting": - return .formatting - case "textDocument/codeAction": - var features: LanguageServerFeatureSet = .codeActions - if options?["resolveProvider"] as? Bool == true { - features.insert(.codeActionResolve) - } - return features - case "workspace/executeCommand": - return .executeCommand - default: - return [] - } - } - - static func serverFeatures(fromInitializeResult value: Any) -> LanguageServerFeatureSet { - guard let result = value as? [String: Any], - let capabilities = result["capabilities"] as? [String: Any] else { return [] } - var features: LanguageServerFeatureSet = [] - func supports(_ key: String) -> Bool { - if let value = capabilities[key] as? Bool { return value } - return capabilities[key] is [String: Any] - } - if supports("definitionProvider") { features.insert(.definition) } - if supports("referencesProvider") { features.insert(.references) } - if supports("implementationProvider") { features.insert(.implementation) } - if supports("hoverProvider") { features.insert(.hover) } - if supports("completionProvider") { - features.insert(.completion) - if (capabilities["completionProvider"] as? [String: Any])?["resolveProvider"] as? Bool == true { - features.insert(.completionResolve) - } - } - if supports("renameProvider") { features.insert(.rename) } - if supports("documentFormattingProvider") { features.insert(.formatting) } - if supports("codeActionProvider") { - features.insert(.codeActions) - if (capabilities["codeActionProvider"] as? [String: Any])?["resolveProvider"] as? Bool == true { - features.insert(.codeActionResolve) - } - } - if supports("executeCommandProvider") { features.insert(.executeCommand) } - return features - } - - static func locations(_ value: Any) -> [LanguageServerLocation] { - let values = (value as? [[String: Any]]) ?? (value as? [String: Any]).map { [$0] } ?? [] - return values.compactMap { value in - let uri = (value["uri"] as? String) ?? (value["targetUri"] as? String) - let rawRange = (value["range"] as? [String: Any]) - ?? (value["targetSelectionRange"] as? [String: Any]) - ?? (value["targetRange"] as? [String: Any]) - guard let uri, let url = URL(string: uri), let rawRange, - let range = range(rawRange) else { return nil } - return LanguageServerLocation(url: url.standardizedFileURL, range: range) - } - } - - static func hover(_ value: Any) -> LanguageServerHover? { - guard !(value is NSNull), let object = value as? [String: Any] else { return nil } - let parsed = markup(object["contents"]) - guard !parsed.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } - return LanguageServerHover( - contents: parsed.text, - isMarkdown: parsed.markdown, - range: (object["range"] as? [String: Any]).flatMap(range) - ) - } - - static func completionItems(_ value: Any) -> [LanguageServerCompletionItem] { - let values = (value as? [[String: Any]]) - ?? ((value as? [String: Any])?["items"] as? [[String: Any]]) - ?? [] - return values.compactMap { item in - guard let label = item["label"] as? String else { return nil } - let rawTextEdit = item["textEdit"] as? [String: Any] - let textEdit = rawTextEdit.flatMap(textEdit) - let documentation = markup(item["documentation"]) - return LanguageServerCompletionItem( - label: label, - detail: item["detail"] as? String, - documentation: documentation.text.isEmpty ? nil : documentation.text, - insertText: (rawTextEdit?["newText"] as? String) ?? (item["insertText"] as? String) ?? label, - sortText: item["sortText"] as? String, - filterText: item["filterText"] as? String, - kind: item["kind"] as? Int, - textEdit: textEdit, - additionalTextEdits: textEdits(item["additionalTextEdits"] as Any), - data: item["data"].flatMap(ToolingJSONValue.fromFoundation) - ) - }.sorted { - ($0.sortText ?? $0.label).localizedStandardCompare($1.sortText ?? $1.label) == .orderedAscending - } - } - - static func textEdits(_ value: Any) -> [LanguageServerTextEdit] { - guard let values = value as? [[String: Any]] else { return [] } - return values.compactMap { value in - guard let rawRange = value["range"] as? [String: Any], - let range = range(rawRange), - let newText = value["newText"] as? String else { return nil } - return LanguageServerTextEdit(range: range, newText: newText) - } - } - - static func workspaceEdit(_ value: Any) -> LanguageServerWorkspaceEdit { - guard let object = value as? [String: Any] else { return LanguageServerWorkspaceEdit() } - var changes: [URL: [LanguageServerTextEdit]] = [:] - if let rawChanges = object["changes"] as? [String: Any] { - for (uri, rawEdits) in rawChanges { - guard let url = URL(string: uri) else { continue } - changes[url.standardizedFileURL] = textEdits(rawEdits) - } - } - if let documentChanges = object["documentChanges"] as? [[String: Any]] { - for change in documentChanges { - guard let document = change["textDocument"] as? [String: Any], - let uri = document["uri"] as? String, - let url = URL(string: uri) else { continue } - changes[url.standardizedFileURL, default: []].append(contentsOf: textEdits(change["edits"] as Any)) - } - } - return LanguageServerWorkspaceEdit(changes: changes) - } - - static func codeActions(_ value: Any) -> [LanguageServerCodeAction] { - guard let values = value as? [Any] else { return [] } - return values.compactMap { raw in - guard let action = raw as? [String: Any], let title = action["title"] as? String else { return nil } - return LanguageServerCodeAction( - title: title, - kind: action["kind"] as? String, - isPreferred: action["isPreferred"] as? Bool ?? false, - edit: action["edit"].map(workspaceEdit), - command: command(action["command"]) ?? command(action), - data: action["data"].flatMap(ToolingJSONValue.fromFoundation) - ) - }.sorted { - if $0.isPreferred != $1.isPreferred { return $0.isPreferred } - return $0.title.localizedStandardCompare($1.title) == .orderedAscending - } - } - - static func range(_ value: [String: Any]) -> LanguageServerRange? { - guard let start = value["start"] as? [String: Any], - let end = value["end"] as? [String: Any], - let startLine = start["line"] as? Int, - let startColumn = start["character"] as? Int, - let endLine = end["line"] as? Int, - let endColumn = end["character"] as? Int else { return nil } - return LanguageServerRange( - start: LanguageServerPosition(line: startLine, utf16Column: startColumn), - end: LanguageServerPosition(line: endLine, utf16Column: endColumn) - ) - } - - static func completionItem(_ value: Any) -> LanguageServerCompletionItem? { - completionItems([value]).first - } - - static func codeAction(_ value: Any) -> LanguageServerCodeAction? { - codeActions([value]).first - } - - static func foundationCompletionItem(_ item: LanguageServerCompletionItem) -> [String: Any] { - var value: [String: Any] = ["label": item.label, "insertText": item.insertText] - if let detail = item.detail { value["detail"] = detail } - if let documentation = item.documentation { - value["documentation"] = ["kind": "markdown", "value": documentation] - } - if let sortText = item.sortText { value["sortText"] = sortText } - if let filterText = item.filterText { value["filterText"] = filterText } - if let kind = item.kind { value["kind"] = kind } - if let textEdit = item.textEdit { value["textEdit"] = foundationTextEdit(textEdit) } - if !item.additionalTextEdits.isEmpty { - value["additionalTextEdits"] = item.additionalTextEdits.map(foundationTextEdit) - } - if let data = item.data { value["data"] = data.foundationObject } - return value - } - - static func foundationCodeAction(_ action: LanguageServerCodeAction) -> [String: Any] { - var value: [String: Any] = ["title": action.title, "isPreferred": action.isPreferred] - if let kind = action.kind { value["kind"] = kind } - if let edit = action.edit { value["edit"] = foundationWorkspaceEdit(edit) } - if let command = action.command { value["command"] = foundationCommand(command) } - if let data = action.data { value["data"] = data.foundationObject } - return value - } - - private static func textEdit(_ value: [String: Any]) -> LanguageServerTextEdit? { - guard let rawRange = value["range"] as? [String: Any], - let range = range(rawRange), - let newText = value["newText"] as? String else { return nil } - return LanguageServerTextEdit(range: range, newText: newText) - } - - private static func command(_ value: Any?) -> LanguageServerCommand? { - guard let object = value as? [String: Any], - let title = object["title"] as? String, - let identifier = object["command"] as? String else { return nil } - return LanguageServerCommand( - title: title, - command: identifier, - arguments: (object["arguments"] as? [Any] ?? []).compactMap(ToolingJSONValue.fromFoundation) - ) - } - - private static func foundationRange(_ range: LanguageServerRange) -> [String: Any] { - [ - "start": ["line": range.start.line, "character": range.start.utf16Column], - "end": ["line": range.end.line, "character": range.end.utf16Column] - ] - } - - private static func foundationTextEdit(_ edit: LanguageServerTextEdit) -> [String: Any] { - ["range": foundationRange(edit.range), "newText": edit.newText] - } - - private static func foundationWorkspaceEdit(_ edit: LanguageServerWorkspaceEdit) -> [String: Any] { - ["changes": Dictionary(uniqueKeysWithValues: edit.changes.map { - ($0.key.absoluteString, $0.value.map(foundationTextEdit)) - })] - } - - private static func foundationCommand(_ command: LanguageServerCommand) -> [String: Any] { - [ - "title": command.title, - "command": command.command, - "arguments": command.arguments.map(\.foundationObject) - ] - } - - private static func markup(_ value: Any?) -> (text: String, markdown: Bool) { - if let text = value as? String { return (text, false) } - if let object = value as? [String: Any], let text = object["value"] as? String { - return (text, object["kind"] as? String == "markdown" || object["language"] != nil) - } - if let values = value as? [Any] { - let parsed = values.map(markup).filter { !$0.text.isEmpty } - return (parsed.map(\.text).joined(separator: "\n\n"), parsed.contains(where: \.markdown)) - } - return ("", false) - } -} diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 264e8a83..3b445f5e 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -20,9 +20,9 @@ enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { } } -/// Owns only active language-tooling processes. The catalog is metadata and -/// providers are created lazily on first use, so opening a workspace does not -/// start five LSPs or Debug Adapters just because they are supported. +/// UI-facing façade for language tooling. LSP behavior is intentionally not +/// implemented in Swift; these entry points are stable while the Rust LSP host +/// is wired underneath them. @MainActor final class LanguageToolingSessionManager: ObservableObject { @Published private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] @@ -30,12 +30,12 @@ final class LanguageToolingSessionManager: ObservableObject { @Published private(set) var debugStates: [String: DebugAdapterState] = [:] @Published private(set) var lastDebugEvents: [String: DebugAdapterEvent] = [:] @Published private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:] + var onDebugStateChange: ((String, DebugAdapterState) -> Void)? var onDebugEvent: ((String, DebugAdapterEvent) -> Void)? - private let catalog: LanguageProviderCatalog + + private var catalog: LanguageProviderCatalog private var runtimesByID: [String: any LanguageProviderRuntime] - private var languageServers: [String: any LanguageServerSession] = [:] - private var languageServerRoots: [String: URL] = [:] private var debugAdapters: [String: any DebugAdapterSession] = [:] private var debugAdapterRoots: [String: URL] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] @@ -52,75 +52,182 @@ final class LanguageToolingSessionManager: ObservableObject { self.init(catalog: registry.catalog, runtimes: registry.toolingRuntimes) } - var activeLanguageServerIDs: Set { Set(languageServers.keys) } + var activeLanguageServerIDs: Set { [] } var activeDebugAdapterIDs: Set { Set(debugAdapters.keys) } + func updateCatalog(_ catalog: LanguageProviderCatalog) { + self.catalog = catalog + let validProviderIDs = Set(catalog.descriptors.map(\.id)) + languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } + diagnostics = diagnostics.filter { catalog.provider(for: $0.key) != nil } + } + func provider(for fileURL: URL) -> LanguageProviderDescriptor? { catalog.provider(for: fileURL) } func supportsGenericEditing(for fileURL: URL) -> Bool { - guard let descriptor = catalog.provider(for: fileURL), - descriptor.capabilities.contains(.languageServer) else { return false } - guard runtimesByID[descriptor.id]?.supportsEditingSession == true else { return false } + guard catalog.provider(for: fileURL)?.capabilities.contains(.languageServer) == true else { + return false + } return !features(for: fileURL).isEmpty } func supportsGenericDebugging(for fileURL: URL) -> Bool { - guard let descriptor = catalog.provider(for: fileURL) else { return false } + guard let descriptor = catalog.provider(for: fileURL), + descriptor.capabilities.contains(.debugAdapter) else { return false } return runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true } func features(for fileURL: URL) -> LanguageServerFeatureSet { guard let descriptor = catalog.provider(for: fileURL) else { return [] } - return languageServerFeatures[descriptor.id] - ?? runtimesByID[descriptor.id]?.declaredLanguageServerFeatures - ?? [] + return languageServerFeatures[descriptor.id] ?? [] } - @discardableResult - func activateLanguageServer(for fileURL: URL, rootURL: URL) throws -> any LanguageServerSession { - guard let descriptor = catalog.provider(for: fileURL) else { + func synchronizeLanguageServer( + for fileURL: URL, + text _: String, + rootURL _: URL + ) throws { + guard catalog.provider(for: fileURL) != nil else { throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) } - guard descriptor.capabilities.contains(.languageServer) else { + // Rust LSP host will own didOpen/didChange and diagnostics. Until it + // exists, document synchronization is a no-op so the UI remains stable. + } + + func closeDocument(_ fileURL: URL) { + diagnostics[fileURL.standardizedFileURL] = nil + } + + func clearDiagnostics() { + diagnostics = [:] + } + + func stopLanguageServer(providerID: String) { + languageServerFeatures[providerID] = nil + } + + func stopAllLanguageServers() { + diagnostics = [:] + languageServerFeatures = [:] + } + + func navigate( + method _: String, + fileURL: URL, + text _: String, + position _: LanguageServerPosition, + rootURL _: URL, + completion _: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + throw unavailableLanguageServerError(for: fileURL) + } + + func hover( + fileURL: URL, + text _: String, + position _: LanguageServerPosition, + rootURL _: URL, + completion _: @escaping (Result) -> Void + ) throws { + throw unavailableLanguageServerError(for: fileURL) + } + + func completions( + fileURL: URL, + text _: String, + position _: LanguageServerPosition, + rootURL _: URL, + completion _: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + throw unavailableLanguageServerError(for: fileURL) + } + + func rename( + fileURL: URL, + text _: String, + position _: LanguageServerPosition, + newName _: String, + rootURL _: URL, + completion _: @escaping (Result) -> Void + ) throws { + throw unavailableLanguageServerError(for: fileURL) + } + + func format( + fileURL: URL, + text _: String, + rootURL _: URL, + options _: [String: Any] = [ + "tabSize": 4, + "insertSpaces": true, + "trimTrailingWhitespace": true, + "insertFinalNewline": true, + "trimFinalNewlines": true + ], + completion _: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void + ) throws { + throw unavailableLanguageServerError(for: fileURL) + } + + func codeActions( + fileURL: URL, + text _: String, + range _: LanguageServerRange, + diagnostics _: [LanguageServerDiagnostic], + rootURL _: URL, + completion _: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void + ) throws { + throw unavailableLanguageServerError(for: fileURL) + } + + func execute( + _ command: LanguageServerCommand, + fileURL: URL, + text _: String, + rootURL _: URL, + completion _: @escaping (Result) -> Void + ) throws { + guard command.command.isEmpty == false else { throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: "language server" + provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + capability: "execute command" ) } - let normalizedRoot = rootURL.standardizedFileURL - if let active = languageServers[descriptor.id] { - if active.isRunning, languageServerRoots[descriptor.id] == normalizedRoot { - return active - } - active.stop() - languageServers[descriptor.id] = nil - languageServerRoots[descriptor.id] = nil - } - guard let runtime = runtimesByID[descriptor.id] else { - throw LanguageToolingSessionError.providerNotInstalled(descriptor.displayName) - } - guard let session = runtime.makeLanguageServerSession() else { - throw LanguageToolingSessionError.toolingUnavailable( - runtime.unavailableToolingMessage ?? descriptor.displayName + throw unavailableLanguageServerError(for: fileURL) + } + + func resolveCompletion( + _ item: LanguageServerCompletionItem, + fileURL: URL, + text _: String, + rootURL _: URL, + completion _: @escaping (Result) -> Void + ) throws { + guard item.label.isEmpty == false else { + throw LanguageToolingSessionError.capabilityUnavailable( + provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + capability: "completion item resolve" ) } - languageServerFeatures[descriptor.id] = runtime.declaredLanguageServerFeatures - if let reporting = session as? any LanguageServerFeatureReportingSession { - reporting.onSupportedFeaturesChange = { [weak self] features in - self?.languageServerFeatures[descriptor.id] = features - } - } - do { - try session.start(rootURL: normalizedRoot) - } catch { - languageServerFeatures[descriptor.id] = nil - throw error + throw unavailableLanguageServerError(for: fileURL) + } + + func resolveCodeAction( + _ action: LanguageServerCodeAction, + fileURL: URL, + text _: String, + rootURL _: URL, + completion _: @escaping (Result) -> Void + ) throws { + guard action.title.isEmpty == false else { + throw LanguageToolingSessionError.capabilityUnavailable( + provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + capability: "code action resolve" + ) } - languageServers[descriptor.id] = session - languageServerRoots[descriptor.id] = normalizedRoot - return session + throw unavailableLanguageServerError(for: fileURL) } @discardableResult @@ -128,8 +235,7 @@ final class LanguageToolingSessionManager: ObservableObject { guard let descriptor = catalog.provider(for: fileURL) else { throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) } - guard descriptor.capabilities.contains(.debugAdapter) - || runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true else { + guard descriptor.capabilities.contains(.debugAdapter) else { throw LanguageToolingSessionError.capabilityUnavailable( provider: descriptor.displayName, capability: "debug adapter" @@ -165,12 +271,6 @@ final class LanguageToolingSessionManager: ObservableObject { return session } - func stopLanguageServer(providerID: String) { - languageServers.removeValue(forKey: providerID)?.stop() - languageServerRoots[providerID] = nil - languageServerFeatures[providerID] = nil - } - func stopDebugAdapter(providerID: String) { debugAdapters.removeValue(forKey: providerID)?.stop() debugAdapterRoots[providerID] = nil @@ -178,10 +278,8 @@ final class LanguageToolingSessionManager: ObservableObject { } func stopAll() { - for session in languageServers.values { session.stop() } for session in debugAdapters.values { session.stop() } - languageServers.removeAll() - languageServerRoots.removeAll() + diagnostics = [:] languageServerFeatures = [:] debugAdapters.removeAll() debugAdapterRoots.removeAll() @@ -189,7 +287,6 @@ final class LanguageToolingSessionManager: ObservableObject { lastDebugEvents = [:] verifiedBreakpoints = [:] requestedBreakpoints = [:] - diagnostics = [:] } @discardableResult @@ -219,8 +316,7 @@ final class LanguageToolingSessionManager: ObservableObject { fileExtension: fileURL.pathExtension.lowercased() ) } - guard descriptor.capabilities.contains(.debugAdapter) - || runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true else { + guard descriptor.capabilities.contains(.debugAdapter) else { throw LanguageToolingSessionError.capabilityUnavailable( provider: descriptor.displayName, capability: "debug adapter breakpoints" @@ -237,322 +333,13 @@ final class LanguageToolingSessionManager: ObservableObject { debugAdapters[providerID] as? any DebugAdapterControllingSession } - func synchronizeLanguageServer( - for fileURL: URL, - text: String, - rootURL: URL - ) throws { - guard let descriptor = catalog.provider(for: fileURL) else { - throw LanguageToolingSessionError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } - let session = try activateLanguageServer(for: fileURL, rootURL: rootURL) - guard let documentSession = session as? any LanguageServerDocumentSession else { return } - documentSession.onDiagnostics = { [weak self] url, diagnostics in - self?.diagnostics[url.standardizedFileURL] = diagnostics - } - documentSession.synchronizeDocument( - url: fileURL, - languageIdentifier: descriptor.languageIdentifier(for: fileURL), - text: text + private func unavailableLanguageServerError(for fileURL: URL) -> LanguageToolingSessionError { + let provider = catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension + return .toolingUnavailable( + "\(provider) language server is waiting for the Rust LSP host integration." ) } - func closeDocument(_ fileURL: URL) { - guard let descriptor = catalog.provider(for: fileURL), - let session = languageServers[descriptor.id] as? any LanguageServerDocumentSession - else { return } - session.closeDocument(url: fileURL) - diagnostics[fileURL.standardizedFileURL] = nil - } - - func navigate( - method: String, - fileURL: URL, - text: String, - position: LanguageServerPosition, - rootURL: URL, - completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void - ) throws { - guard let descriptor = catalog.provider(for: fileURL) else { - throw LanguageToolingSessionError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } - let feature: LanguageServerFeatureSet - let capability: String - switch method { - case "textDocument/definition": - feature = .definition - capability = "go to definition" - case "textDocument/references": - feature = .references - capability = "find references" - case "textDocument/implementation": - feature = .implementation - capability = "go to implementation" - default: - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: method - ) - } - let session = try activateLanguageServer(for: fileURL, rootURL: rootURL) - try requireFeature(feature, descriptor: descriptor, capability: capability) - guard let navigation = session as? any LanguageServerNavigationSession else { - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: "definition and reference navigation" - ) - } - navigation.onDiagnostics = { [weak self] url, diagnostics in - self?.diagnostics[url.standardizedFileURL] = diagnostics - } - navigation.synchronizeDocument( - url: fileURL, - languageIdentifier: descriptor.languageIdentifier(for: fileURL), - text: text - ) - navigation.locations( - method: method, - documentURL: fileURL, - position: position, - completion: completion - ) - } - - func hover( - fileURL: URL, - text: String, - position: LanguageServerPosition, - rootURL: URL, - completion: @escaping (Result) -> Void - ) throws { - let session = try codeIntelligenceSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .hover, - capability: "hover" - ) - session.hover(documentURL: fileURL, position: position, completion: completion) - } - - func completions( - fileURL: URL, - text: String, - position: LanguageServerPosition, - rootURL: URL, - completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void - ) throws { - let session = try codeIntelligenceSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .completion, - capability: "completion" - ) - session.completions(documentURL: fileURL, position: position, completion: completion) - } - - func rename( - fileURL: URL, - text: String, - position: LanguageServerPosition, - newName: String, - rootURL: URL, - completion: @escaping (Result) -> Void - ) throws { - let session = try editingSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .rename, - capability: "rename" - ) - session.rename( - documentURL: fileURL, - position: position, - newName: newName, - completion: completion - ) - } - - func format( - fileURL: URL, - text: String, - rootURL: URL, - options: [String: Any] = [ - "tabSize": 4, - "insertSpaces": true, - "trimTrailingWhitespace": true, - "insertFinalNewline": true, - "trimFinalNewlines": true - ], - completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void - ) throws { - let session = try editingSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .formatting, - capability: "document formatting" - ) - session.formatting(documentURL: fileURL, options: options, completion: completion) - } - - func codeActions( - fileURL: URL, - text: String, - range: LanguageServerRange, - diagnostics: [LanguageServerDiagnostic], - rootURL: URL, - completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void - ) throws { - let session = try editingSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .codeActions, - capability: "code actions" - ) - session.codeActions( - documentURL: fileURL, - range: range, - diagnostics: diagnostics, - completion: completion - ) - } - - func execute( - _ command: LanguageServerCommand, - fileURL: URL, - text: String, - rootURL: URL, - completion: @escaping (Result) -> Void - ) throws { - let session = try editingSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .executeCommand, - capability: "execute command" - ) - session.execute(command: command, completion: completion) - } - - func resolveCompletion( - _ item: LanguageServerCompletionItem, - fileURL: URL, - text: String, - rootURL: URL, - completion: @escaping (Result) -> Void - ) throws { - let session = try editingSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .completionResolve, - capability: "completion item resolve" - ) - session.resolveCompletion(item, completion: completion) - } - - func resolveCodeAction( - _ action: LanguageServerCodeAction, - fileURL: URL, - text: String, - rootURL: URL, - completion: @escaping (Result) -> Void - ) throws { - let session = try editingSession( - fileURL: fileURL, - text: text, - rootURL: rootURL, - requiredFeature: .codeActionResolve, - capability: "code action resolve" - ) - session.resolveCodeAction(action, completion: completion) - } - - private func codeIntelligenceSession( - fileURL: URL, - text: String, - rootURL: URL, - requiredFeature: LanguageServerFeatureSet, - capability: String - ) throws -> any LanguageServerCodeIntelligenceSession { - guard let descriptor = catalog.provider(for: fileURL) else { - throw LanguageToolingSessionError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } - let session = try activateLanguageServer(for: fileURL, rootURL: rootURL) - try requireFeature(requiredFeature, descriptor: descriptor, capability: capability) - guard let intelligence = session as? any LanguageServerCodeIntelligenceSession else { - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: "hover and completion" - ) - } - intelligence.onDiagnostics = { [weak self] url, diagnostics in - self?.diagnostics[url.standardizedFileURL] = diagnostics - } - intelligence.synchronizeDocument( - url: fileURL, - languageIdentifier: descriptor.languageIdentifier(for: fileURL), - text: text - ) - return intelligence - } - - private func editingSession( - fileURL: URL, - text: String, - rootURL: URL, - requiredFeature: LanguageServerFeatureSet, - capability: String - ) throws -> any LanguageServerEditingSession { - guard let descriptor = catalog.provider(for: fileURL) else { - throw LanguageToolingSessionError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } - let session = try activateLanguageServer(for: fileURL, rootURL: rootURL) - try requireFeature(requiredFeature, descriptor: descriptor, capability: capability) - guard let editing = session as? any LanguageServerEditingSession else { - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: "rename, formatting and code actions" - ) - } - editing.onDiagnostics = { [weak self] url, diagnostics in - self?.diagnostics[url.standardizedFileURL] = diagnostics - } - editing.synchronizeDocument( - url: fileURL, - languageIdentifier: descriptor.languageIdentifier(for: fileURL), - text: text - ) - return editing - } - - private func requireFeature( - _ feature: LanguageServerFeatureSet, - descriptor: LanguageProviderDescriptor, - capability: String - ) throws { - guard (languageServerFeatures[descriptor.id] - ?? runtimesByID[descriptor.id]?.declaredLanguageServerFeatures - ?? []).contains(feature) else { - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: capability - ) - } - } - private func configureDebugCallbacks( _ session: any DebugAdapterSession, providerID: String diff --git a/Sources/Lithe/Services/ProjectRuntimeService.swift b/Sources/Lithe/Services/ProjectRuntimeService.swift index 128c7a09..8a316138 100644 --- a/Sources/Lithe/Services/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/ProjectRuntimeService.swift @@ -272,8 +272,7 @@ final class ProjectRuntimeService: ObservableObject { projectURL: projectURL, javaHomePath: configuredPath, javaExecutablePath: nil, - jdbExecutablePath: nil, - languageServerExecutablePath: runtimeLocator.javaLanguageServerExecutable()?.path + jdbExecutablePath: nil ) return } @@ -289,8 +288,7 @@ final class ProjectRuntimeService: ObservableObject { projectURL: projectURL, javaHomePath: nil, javaExecutablePath: nil, - jdbExecutablePath: runtimeLocator.systemJDBExecutable()?.path, - languageServerExecutablePath: runtimeLocator.javaLanguageServerExecutable()?.path + jdbExecutablePath: runtimeLocator.systemJDBExecutable()?.path ) return } @@ -306,20 +304,17 @@ final class ProjectRuntimeService: ObservableObject { projectURL: projectURL, javaHomePath: javaHome.path, javaExecutablePath: javaExecutable.path, - jdbExecutablePath: nil, - languageServerExecutablePath: runtimeLocator.javaLanguageServerExecutable()?.path + jdbExecutablePath: nil ) return } - let languageServer = runtimeLocator.javaLanguageServerExecutable() javaEnvironmentReport = JavaEnvironmentReport( - status: languageServer == nil ? .languageServerMissing : .ready, + status: .ready, projectURL: projectURL, javaHomePath: javaHome.path, javaExecutablePath: javaExecutable.path, - jdbExecutablePath: jdbExecutable.path, - languageServerExecutablePath: languageServer?.path + jdbExecutablePath: jdbExecutable.path ) } @@ -411,10 +406,6 @@ final class ProjectRuntimeService: ObservableObject { return result } - func javaLanguageServerExecutable() -> URL? { - runtimeLocator.javaLanguageServerExecutable() - } - private func normalizedPath(_ path: String) -> String { ((path as NSString).expandingTildeInPath as NSString).standardizingPath } diff --git a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift index 4b7a6abf..a6d0c442 100644 --- a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift +++ b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift @@ -5,47 +5,32 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { let descriptor: LanguageProviderDescriptor private let runtimeService: ProjectRuntimeService private let processFactory: () -> any RawProcessSession - private let launch: StdioLanguageServerLaunch private let debugLaunch: StdioDebugAdapterLaunch? private let debugSessionFactory: (() -> (any DebugAdapterSession)?)? - var supportsEditingSession: Bool { true } - var supportsDebugAdapterSession: Bool { debugLaunch != nil || debugSessionFactory != nil } + var supportsDebugAdapterSession: Bool { + debugLaunch != nil || debugSessionFactory != nil + } + var unavailableToolingMessage: String? { - let command = debugLaunch?.executableNames.first ?? launch.executableNames.first - guard let command else { return nil } + guard let command = debugLaunch?.executableNames.first else { return nil } return runtimeService.missingToolMessage(command) } - var declaredLanguageServerFeatures: LanguageServerFeatureSet { .standardEditing } init( descriptor: LanguageProviderDescriptor, runtimeService: ProjectRuntimeService, processFactory: @escaping () -> any RawProcessSession, - launch: StdioLanguageServerLaunch, debugLaunch: StdioDebugAdapterLaunch? = nil, debugSessionFactory: (() -> (any DebugAdapterSession)?)? = nil ) { self.descriptor = descriptor self.runtimeService = runtimeService self.processFactory = processFactory - self.launch = launch self.debugLaunch = debugLaunch self.debugSessionFactory = debugSessionFactory } - func makeLanguageServerSession() -> (any LanguageServerSession)? { - guard let executableURL = launch.executableNames.lazy.compactMap({ - self.runtimeService.executableOnPath($0) - }).first else { return nil } - return StdioLanguageServerSession( - executableURL: executableURL, - arguments: launch.arguments, - environment: runtimeService.processEnvironment(), - process: processFactory() - ) - } - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { if let debugSessionFactory { return debugSessionFactory() } guard let debugLaunch else { return nil } @@ -67,9 +52,6 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { ) } - /// Builds standard stdio runtimes from language-pack metadata. No - /// language identifiers or executable maps live in this runtime anymore; - /// adding a provider means adding its launch definition to its pack. static func standard( packs: [LanguagePack], runtimeService: ProjectRuntimeService, @@ -77,12 +59,13 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] ) -> [any LanguageProviderRuntime] { packs.compactMap { pack in - guard let launch = pack.languageServerLaunch else { return nil } + guard pack.descriptor.capabilities.contains(.debugAdapter) else { return nil } + guard pack.debugAdapterLaunch != nil || debugSessionFactories[pack.descriptor.id] != nil + else { return nil } return StdioLanguageProviderRuntime( descriptor: pack.descriptor, runtimeService: runtimeService, processFactory: processFactory, - launch: launch, debugLaunch: pack.debugAdapterLaunch, debugSessionFactory: debugSessionFactories[pack.descriptor.id] ) @@ -103,582 +86,3 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { ) } } - -@MainActor -final class StdioLanguageServerSession: LanguageServerEditingSession, LanguageServerFeatureReportingSession { - enum SessionError: LocalizedError { - case launchFailed(String) - case stopped - case requestFailed(String) - - var errorDescription: String? { - switch self { - case .launchFailed(let message): message - case .stopped: "The language server stopped before answering." - case .requestFailed(let message): message - } - } - } - - private typealias ResponseHandler = (Result) -> Void - - private let executableURL: URL - private let arguments: [String] - private let environment: [String: String] - private let process: any RawProcessSession - private var readBuffer = Data() - private var initializeRequestID: Int? - private var nextRequestID = 1 - private var responseHandlers: [Int: ResponseHandler] = [:] - private var pendingRequests: [(String, [String: Any], ResponseHandler)] = [] - private var initializedFeatures: LanguageServerFeatureSet = .standardEditing - private var dynamicallyRegisteredFeatures: [String: LanguageServerFeatureSet] = [:] - private var documentsByURI: [String: PendingDocument] = [:] - private var openedDocumentVersions: [String: Int] = [:] - private var lastSentDocumentTextByURI: [String: String] = [:] - private(set) var isReady = false - private(set) var supportedFeatures: LanguageServerFeatureSet = .standardEditing - var onSupportedFeaturesChange: ((LanguageServerFeatureSet) -> Void)? - var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? - - private struct PendingDocument { - let url: URL - let languageIdentifier: String - let text: String - } - - init( - executableURL: URL, - arguments: [String], - environment: [String: String], - process: any RawProcessSession - ) { - self.executableURL = executableURL - self.arguments = arguments - self.environment = environment - self.process = process - process.onOutput = { [weak self] data in - Task { @MainActor [weak self] in self?.receive(data) } - } - process.onTermination = { [weak self] _ in - Task { @MainActor [weak self] in - self?.isReady = false - self?.initializeRequestID = nil - self?.failPendingRequests(SessionError.stopped) - } - } - } - - var isRunning: Bool { process.isRunning } - - func start(rootURL: URL) throws { - if process.isRunning { return } - isReady = false - initializedFeatures = .standardEditing - dynamicallyRegisteredFeatures = [:] - publishSupportedFeatures() - readBuffer = Data() - openedDocumentVersions = [:] - let operationID = UUID().uuidString - do { - try process.start(ProcessRequest( - operationID: operationID, - executablePath: executableURL.path, - arguments: arguments, - workingDirectory: rootURL.standardizedFileURL.path, - environment: environment, - keepsStandardInputOpen: true - )) - let id = nextRequestID - nextRequestID += 1 - initializeRequestID = id - send([ - "jsonrpc": "2.0", - "id": id, - "method": "initialize", - "params": [ - "processId": ProcessInfo.processInfo.processIdentifier, - "clientInfo": ["name": "Lithe", "version": "0.1.0"], - "rootUri": rootURL.standardizedFileURL.absoluteString, - "capabilities": [ - "workspace": [ - "workspaceFolders": true, - "configuration": true, - "applyEdit": true - ], - "textDocument": [ - "synchronization": ["didSave": true], - "publishDiagnostics": ["relatedInformation": true], - "definition": ["dynamicRegistration": true, "linkSupport": true], - "references": ["dynamicRegistration": true], - "implementation": ["dynamicRegistration": true, "linkSupport": true], - "hover": ["dynamicRegistration": true, "contentFormat": ["markdown", "plaintext"]], - "completion": ["dynamicRegistration": true, "completionItem": [ - "documentationFormat": ["markdown", "plaintext"], - "snippetSupport": true, - "resolveSupport": ["properties": ["detail", "documentation", "textEdit", "additionalTextEdits"]] - ]], - "rename": ["dynamicRegistration": true, "prepareSupport": true], - "formatting": ["dynamicRegistration": true], - "codeAction": [ - "dynamicRegistration": true, - "resolveSupport": ["properties": ["edit", "command"]], - "codeActionLiteralSupport": [ - "codeActionKind": ["valueSet": ["quickfix", "refactor", "source"]] - ] - ] - ], - "executeCommand": ["dynamicRegistration": true] - ], - "workspaceFolders": [[ - "uri": rootURL.standardizedFileURL.absoluteString, - "name": rootURL.lastPathComponent - ]] - ] - ]) - } catch { - process.stop() - throw SessionError.launchFailed(error.localizedDescription) - } - } - - func stop() { - if process.isRunning { - send(["jsonrpc": "2.0", "method": "exit", "params": [:]]) - } - process.stop() - isReady = false - initializeRequestID = nil - readBuffer = Data() - openedDocumentVersions = [:] - initializedFeatures = .standardEditing - dynamicallyRegisteredFeatures = [:] - lastSentDocumentTextByURI = [:] - documentsByURI = [:] - failPendingRequests(SessionError.stopped) - } - - func synchronizeDocument(url: URL, languageIdentifier: String, text: String) { - let normalizedURL = url.standardizedFileURL - let uri = normalizedURL.absoluteString - documentsByURI[uri] = PendingDocument( - url: normalizedURL, - languageIdentifier: languageIdentifier, - text: text - ) - guard isReady else { return } - synchronize(uri: uri) - } - - func closeDocument(url: URL) { - let normalizedURL = url.standardizedFileURL - let uri = normalizedURL.absoluteString - documentsByURI[uri] = nil - lastSentDocumentTextByURI[uri] = nil - if openedDocumentVersions.removeValue(forKey: uri) != nil, isReady { - send([ - "jsonrpc": "2.0", - "method": "textDocument/didClose", - "params": ["textDocument": ["uri": uri]] - ]) - } - onDiagnostics?(normalizedURL, []) - } - - func locations( - method: String, - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void - ) { - var parameters: [String: Any] = [ - "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], - "position": ["line": position.line, "character": position.utf16Column] - ] - if method == "textDocument/references" { - parameters["context"] = ["includeDeclaration": true] - } - request(method: method, parameters: parameters) { result in - completion(result.map(LanguageServerResponseParser.locations)) - } - } - - func hover( - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result) -> Void - ) { - request( - method: "textDocument/hover", - parameters: positionParameters(documentURL: documentURL, position: position) - ) { result in - completion(result.map(LanguageServerResponseParser.hover)) - } - } - - func completions( - documentURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void - ) { - var parameters = positionParameters(documentURL: documentURL, position: position) - parameters["context"] = ["triggerKind": 1] - request(method: "textDocument/completion", parameters: parameters) { result in - completion(result.map(LanguageServerResponseParser.completionItems)) - } - } - - func rename( - documentURL: URL, - position: LanguageServerPosition, - newName: String, - completion: @escaping (Result) -> Void - ) { - var parameters = positionParameters(documentURL: documentURL, position: position) - parameters["newName"] = newName - request(method: "textDocument/rename", parameters: parameters) { result in - completion(result.map(LanguageServerResponseParser.workspaceEdit)) - } - } - - func formatting( - documentURL: URL, - options: [String: Any], - completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void - ) { - let parameters: [String: Any] = [ - "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], - "options": options - ] - request(method: "textDocument/formatting", parameters: parameters) { result in - completion(result.map(LanguageServerResponseParser.textEdits)) - } - } - - func codeActions( - documentURL: URL, - range: LanguageServerRange, - diagnostics: [LanguageServerDiagnostic], - completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void - ) { - let diagnosticValues: [[String: Any]] = diagnostics.map { diagnostic in - var value: [String: Any] = [ - "range": Self.foundationRange(diagnostic.range), - "message": diagnostic.message - ] - if let severity = diagnostic.severity { value["severity"] = severity } - if let source = diagnostic.source { value["source"] = source } - if let code = diagnostic.code { value["code"] = code } - return value - } - let parameters: [String: Any] = [ - "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], - "range": Self.foundationRange(range), - "context": ["diagnostics": diagnosticValues, "only": ["quickfix", "refactor"]] - ] - request(method: "textDocument/codeAction", parameters: parameters) { result in - completion(result.map(LanguageServerResponseParser.codeActions)) - } - } - - func execute( - command: LanguageServerCommand, - completion: @escaping (Result) -> Void - ) { - request(method: "workspace/executeCommand", parameters: [ - "command": command.command, - "arguments": command.arguments.map(\.foundationObject) - ]) { result in - completion(result.map { _ in () }) - } - } - - func resolveCompletion( - _ item: LanguageServerCompletionItem, - completion: @escaping (Result) -> Void - ) { - request( - method: "completionItem/resolve", - parameters: LanguageServerResponseParser.foundationCompletionItem(item) - ) { result in - completion(result.flatMap { value in - guard let item = LanguageServerResponseParser.completionItem(value) else { - return .failure(SessionError.requestFailed("The language server returned an invalid completion item.")) - } - return .success(item) - }) - } - } - - func resolveCodeAction( - _ action: LanguageServerCodeAction, - completion: @escaping (Result) -> Void - ) { - request( - method: "codeAction/resolve", - parameters: LanguageServerResponseParser.foundationCodeAction(action) - ) { result in - completion(result.flatMap { value in - guard let action = LanguageServerResponseParser.codeAction(value) else { - return .failure(SessionError.requestFailed("The language server returned an invalid code action.")) - } - return .success(action) - }) - } - } - - private func positionParameters( - documentURL: URL, - position: LanguageServerPosition - ) -> [String: Any] { - [ - "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], - "position": ["line": position.line, "character": position.utf16Column] - ] - } - - private func send(_ message: [String: Any]) { - guard JSONSerialization.isValidJSONObject(message), - let body = try? JSONSerialization.data(withJSONObject: message) else { return } - var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8) - framed.append(body) - try? process.send(framed) - } - - private func receive(_ data: Data) { - readBuffer.append(data) - while let headerEnd = readBuffer.range(of: Data("\r\n\r\n".utf8)) { - let headerData = readBuffer[..= bodyStart + contentLength else { return } - let body = readBuffer.subdata(in: bodyStart..<(bodyStart + contentLength)) - readBuffer.removeSubrange(0..<(bodyStart + contentLength)) - guard let message = try? JSONSerialization.jsonObject(with: body) as? [String: Any] - else { continue } - handle(message) - } - } - - private func handle(_ message: [String: Any]) { - if let id = message["id"] as? Int, - id == initializeRequestID, - message["method"] == nil { - initializeRequestID = nil - guard message["error"] == nil else { - process.stop() - isReady = false - return - } - initializedFeatures = LanguageServerResponseParser.serverFeatures( - fromInitializeResult: message["result"] ?? NSNull() - ) - dynamicallyRegisteredFeatures = [:] - publishSupportedFeatures() - send(["jsonrpc": "2.0", "method": "initialized", "params": [:]]) - isReady = true - for uri in documentsByURI.keys.sorted() { - synchronize(uri: uri) - } - flushPendingRequests() - return - } - if let id = message["id"] as? Int, - message["method"] == nil, - let handler = responseHandlers.removeValue(forKey: id) { - if let error = message["error"] as? [String: Any] { - handler(.failure(SessionError.requestFailed( - error["message"] as? String ?? "The language server request failed." - ))) - } else { - handler(.success(message["result"] ?? NSNull())) - } - return - } - if message["method"] as? String == "textDocument/publishDiagnostics", - let parameters = message["params"] as? [String: Any] { - publishDiagnostics(parameters) - return - } - if let method = message["method"] as? String, - let id = message["id"], - method == "client/registerCapability" { - registerCapabilities(message["params"] as? [String: Any]) - send(["jsonrpc": "2.0", "id": id, "result": NSNull()]) - return - } - if let method = message["method"] as? String, - let id = message["id"], - method == "client/unregisterCapability" { - unregisterCapabilities(message["params"] as? [String: Any]) - send(["jsonrpc": "2.0", "id": id, "result": NSNull()]) - return - } - guard message["method"] != nil, let id = message["id"] else { return } - send(["jsonrpc": "2.0", "id": id, "result": NSNull()]) - } - - private func registerCapabilities(_ parameters: [String: Any]?) { - let registrations = parameters?["registrations"] as? [[String: Any]] ?? [] - for registration in registrations { - guard let id = registration["id"] as? String, - let method = registration["method"] as? String else { continue } - dynamicallyRegisteredFeatures[id] = LanguageServerResponseParser.registeredFeatures( - for: method, - registerOptions: registration["registerOptions"] - ) - } - publishSupportedFeatures() - } - - private func unregisterCapabilities(_ parameters: [String: Any]?) { - let registrations = (parameters?["unregisterations"] as? [[String: Any]]) - ?? (parameters?["unregistrations"] as? [[String: Any]]) - ?? [] - for registration in registrations { - guard let id = registration["id"] as? String else { continue } - dynamicallyRegisteredFeatures[id] = nil - } - publishSupportedFeatures() - } - - private func publishSupportedFeatures() { - supportedFeatures = dynamicallyRegisteredFeatures.values.reduce(initializedFeatures) { - $0.union($1) - } - onSupportedFeaturesChange?(supportedFeatures) - } - - private func request( - method: String, - parameters: [String: Any], - completion: @escaping ResponseHandler - ) { - guard process.isRunning else { - completion(.failure(SessionError.stopped)) - return - } - guard isReady else { - pendingRequests.append((method, parameters, completion)) - return - } - sendRequest(method: method, parameters: parameters, completion: completion) - } - - private func sendRequest( - method: String, - parameters: [String: Any], - completion: @escaping ResponseHandler - ) { - let requiredFeature = LanguageServerResponseParser.requiredFeature(forRequestMethod: method) - guard requiredFeature.isEmpty || supportedFeatures.contains(requiredFeature) else { - completion(.failure(SessionError.requestFailed( - "The language server does not support \(method)." - ))) - return - } - let id = nextRequestID - nextRequestID += 1 - responseHandlers[id] = completion - send(["jsonrpc": "2.0", "id": id, "method": method, "params": parameters]) - } - - private func flushPendingRequests() { - let requests = pendingRequests - pendingRequests = [] - for (method, parameters, completion) in requests { - sendRequest(method: method, parameters: parameters, completion: completion) - } - } - - private func failPendingRequests(_ error: Error) { - let handlers = responseHandlers.values - let queued = pendingRequests.map(\.2) - responseHandlers = [:] - pendingRequests = [] - handlers.forEach { $0(.failure(error)) } - queued.forEach { $0(.failure(error)) } - } - - private func synchronize(uri: String) { - guard let document = documentsByURI[uri] else { return } - if let version = openedDocumentVersions[uri] { - guard lastSentDocumentTextByURI[uri] != document.text else { return } - let nextVersion = version + 1 - openedDocumentVersions[uri] = nextVersion - lastSentDocumentTextByURI[uri] = document.text - send([ - "jsonrpc": "2.0", - "method": "textDocument/didChange", - "params": [ - "textDocument": ["uri": uri, "version": nextVersion], - "contentChanges": [["text": document.text]] - ] - ]) - } else { - openedDocumentVersions[uri] = 1 - lastSentDocumentTextByURI[uri] = document.text - send([ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": [ - "textDocument": [ - "uri": uri, - "languageId": document.languageIdentifier, - "version": 1, - "text": document.text - ] - ] - ]) - } - } - - private func publishDiagnostics(_ parameters: [String: Any]) { - guard let uri = parameters["uri"] as? String, - let url = URL(string: uri) else { return } - let diagnostics = (parameters["diagnostics"] as? [[String: Any]] ?? []).compactMap { - Self.parseDiagnostic($0) - } - onDiagnostics?(url.standardizedFileURL, diagnostics) - } - - private static func parseDiagnostic(_ value: [String: Any]) -> LanguageServerDiagnostic? { - guard let range = value["range"] as? [String: Any], - let start = range["start"] as? [String: Any], - let end = range["end"] as? [String: Any], - let startLine = start["line"] as? Int, - let startColumn = start["character"] as? Int, - let endLine = end["line"] as? Int, - let endColumn = end["character"] as? Int, - let message = value["message"] as? String else { return nil } - let code = (value["code"] as? String) - ?? (value["code"] as? Int).map(String.init) - return LanguageServerDiagnostic( - range: LanguageServerRange( - start: LanguageServerPosition(line: startLine, utf16Column: startColumn), - end: LanguageServerPosition(line: endLine, utf16Column: endColumn) - ), - severity: value["severity"] as? Int, - message: message, - source: value["source"] as? String, - code: code - ) - } - - private static func foundationRange(_ range: LanguageServerRange) -> [String: Any] { - [ - "start": ["line": range.start.line, "character": range.start.utf16Column], - "end": ["line": range.end.line, "character": range.end.utf16Column] - ] - } - -} diff --git a/Sources/Lithe/Views/JavaReferencesView.swift b/Sources/Lithe/Views/JavaReferencesView.swift index ed291046..043a7027 100644 --- a/Sources/Lithe/Views/JavaReferencesView.swift +++ b/Sources/Lithe/Views/JavaReferencesView.swift @@ -106,9 +106,8 @@ struct LanguageImplementationChooserView: View { model.navigate(to: location) } label: { HStack(spacing: 9) { - Image(systemName: "c.circle") - .foregroundStyle(Color(red: 0.42, green: 0.66, blue: 0.95)) - Text(location.displayName.replacingOccurrences(of: ".java", with: "")) + LitheIcon(kind: LitheIcons.kind(for: location.url, isDirectory: false), size: 15) + Text(location.displayName) .font(.system(size: 12.5, weight: .medium, design: .monospaced)) .foregroundStyle(LitheTheme.primaryText) Text(location.displayPath ?? model.relativePath(for: location.url)) diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift new file mode 100644 index 00000000..bcbd5aa2 --- /dev/null +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -0,0 +1,688 @@ +import SwiftUI + +struct LSPControlCenterView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings + + private let metricColumns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ] + + private var copy: LSPControlCenterCopy { + LSPControlCenterCopy(language: settings.language) + } + + var body: some View { + VStack(spacing: 0) { + header + + ScrollView(.vertical) { + VStack(spacing: 8) { + globalControls + serverList + if let selected = selectedDescriptor { + serverDetail(selected) + } else { + emptyDetail + } + diagnosticLog + } + .padding(8) + } + .background(LitheTheme.editor) + } + .background(LitheTheme.editor) + } + + private var header: some View { + HStack(spacing: 8) { + Text(copy.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 0) + Button { + model.isLSPControlCenterVisible = false + } label: { + Image(systemName: "xmark") + } + .litheIconButton() + .help(copy.hideControlCenter) + } + .padding(.leading, 12) + .padding(.trailing, 5) + .frame(height: 34) + .background(LitheTheme.toolHeader) + } + + private var globalControls: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + Text(copy.currentProject) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + Text(model.projectName) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 0) + statusPill( + title: activeServerCount > 0 ? copy.lspActive : copy.onDemand, + color: activeServerCount > 0 ? LitheTheme.success : LitheTheme.secondaryText + ) + } + + HStack(spacing: 8) { + Button { + model.restartLanguageServers() + } label: { + Label(copy.restartAll, systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(LithePrimaryButtonStyle()) + + Button { + model.clearLanguageServerDiagnostics() + } label: { + Label(copy.clearDiagnostics, systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(LitheSecondaryButtonStyle()) + } + } + .padding(10) + .panelChrome() + } + + private var serverList: some View { + VStack(alignment: .leading, spacing: 7) { + sectionTitle(copy.languageServers) + + VStack(spacing: 1) { + ForEach(languageServerDescriptors) { descriptor in + serverRow(descriptor) + } + } + } + .padding(10) + .panelChrome() + } + + private func serverRow(_ descriptor: LanguageProviderDescriptor) -> some View { + let metrics = providerMetrics(for: descriptor) + let isSelected = descriptor.id == selectedDescriptor?.id + + return HStack(spacing: 8) { + Circle() + .fill(statusColor(metrics.status)) + .frame(width: 8, height: 8) + providerIcon(for: descriptor) + VStack(alignment: .leading, spacing: 2) { + Text(descriptor.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Text(metrics.subtitle) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + Text(copy.title(for: metrics.status)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(statusColor(metrics.status)) + Button { + model.languageToolingSessions.stopLanguageServer(providerID: descriptor.id) + } label: { + Image(systemName: metrics.status == .active ? "stop" : "play") + } + .litheIconButton() + .help( + metrics.status == .active + ? copy.stopProvider(descriptor.displayName) + : copy.providerStartsOnDemand(descriptor.displayName) + ) + } + .padding(.horizontal, 7) + .frame(height: 38) + .litheRowHover(isActive: isSelected, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) + } + + private func serverDetail(_ descriptor: LanguageProviderDescriptor) -> some View { + let metrics = providerMetrics(for: descriptor) + return VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 9) { + providerIcon(for: descriptor) + .frame(width: 30, height: 30) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 7) { + Text(descriptor.displayName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + statusPill(title: copy.title(for: metrics.status), color: statusColor(metrics.status)) + } + Text(metrics.workspacePath) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + Text(metrics.version) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + + HStack(spacing: 0) { + summaryStat(copy.rootFiles, value: "\(metrics.fileCount)") + summaryStat(copy.openFiles, value: "\(metrics.openFileCount)") + summaryStat(copy.diagnostics, value: "\(metrics.diagnosticCount)") + } + + LazyVGrid(columns: metricColumns, spacing: 8) { + metricCard(title: copy.features, value: "\(metrics.featureCount)", color: LitheTheme.accent, progress: metrics.featureProgress) + metricCard(title: copy.indexed, value: metrics.indexProgressText, color: LitheTheme.success, progress: metrics.indexProgress) + metricCard(title: copy.errors, value: "\(metrics.errorCount)", color: LitheTheme.error, progress: metrics.errorProgress) + metricCard(title: copy.warnings, value: "\(metrics.warningCount)", color: LitheTheme.warning, progress: metrics.warningProgress) + } + + capabilityGrid(descriptor) + configurationSection(descriptor) + } + .padding(10) + .panelChrome() + } + + private var emptyDetail: some View { + VStack(spacing: 8) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 26, weight: .light)) + .foregroundStyle(LitheTheme.secondaryText) + Text(copy.openSupportedFile) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text(copy.matchingServerWillAppear) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .panelChrome() + } + + private var diagnosticLog: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + sectionTitle(copy.diagnostics) + Spacer(minLength: 0) + Text("\(allDiagnostics.count)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + + if allDiagnostics.isEmpty { + Text(copy.noLanguageServerDiagnostics) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + } else { + VStack(spacing: 1) { + ForEach(Array(allDiagnostics.prefix(5).enumerated()), id: \.offset) { _, diagnostic in + Button { + model.openDiagnostic(diagnostic) + } label: { + HStack(spacing: 7) { + Image(systemName: diagnostic.severity.systemImage) + .foregroundStyle(color(for: diagnostic.severity)) + .frame(width: 14) + Text(diagnostic.locationTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Text(diagnostic.message) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .frame(height: 25) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + } + } + } + .padding(10) + .panelChrome() + } + + private func capabilityGrid(_ descriptor: LanguageProviderDescriptor) -> some View { + let rows: [(String, String, Bool)] = [ + (copy.definition, "arrowshape.turn.up.right", descriptor.capabilities.contains(.languageServer)), + (copy.completion, "text.cursor", descriptor.capabilities.contains(.languageServer)), + (copy.formatting, "text.alignleft", descriptor.capabilities.contains(.formatting)), + (copy.testing, "checkmark.seal", descriptor.capabilities.contains(.testing)), + (copy.debug, "ladybug", descriptor.capabilities.contains(.debugAdapter)), + (copy.run, "play", descriptor.capabilities.contains(.run)) + ] + + return VStack(alignment: .leading, spacing: 7) { + sectionTitle(copy.capabilities) + LazyVGrid(columns: metricColumns, spacing: 6) { + ForEach(rows, id: \.0) { row in + HStack(spacing: 7) { + Image(systemName: row.1) + .frame(width: 15) + Text(row.0) + .lineLimit(1) + Spacer(minLength: 0) + Toggle("", isOn: .constant(row.2)) + .labelsHidden() + .toggleStyle(.switch) + .scaleEffect(0.62) + .allowsHitTesting(false) + } + .font(.system(size: 11.5)) + .foregroundStyle(row.2 ? LitheTheme.primaryText : LitheTheme.secondaryText) + .padding(.horizontal, 7) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(LitheTheme.raised.opacity(0.55)) + ) + } + } + } + } + + private func configurationSection(_ descriptor: LanguageProviderDescriptor) -> some View { + return VStack(alignment: .leading, spacing: 7) { + sectionTitle(copy.providerConfiguration) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + Image(systemName: "curlybraces.square") + .foregroundStyle(LitheTheme.accent) + Text(copy.rustOwnedConfiguration) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 0) + } + configRow(title: copy.providerID, value: descriptor.id) + configRow(title: copy.activation, value: copy.activationPolicy(descriptor.activationPolicy)) + configRow(title: copy.builtinCatalog, value: "rust/lithe-core/resources/lsp/language-providers.json") + configRow(title: copy.projectOverride, value: projectLSPConfigPath) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(LitheTheme.raised.opacity(0.55)) + ) + + Text(copy.configurationHint) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(2) + } + } + + private func configRow(title: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 82, alignment: .leading) + Text(value) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + } + + private func metricCard(title: String, value: String, color: Color, progress: Double) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + Text(value) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + ProgressView(value: min(max(progress, 0), 1)) + .tint(color) + .controlSize(.small) + } + .padding(9) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(LitheTheme.raised.opacity(0.58)) + ) + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } + + private func summaryStat(_ title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) + } + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + } + + private func statusPill(title: String, color: Color) -> some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(title) + .lineLimit(1) + } + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .frame(height: 22) + .background( + Capsule() + .fill(color.opacity(0.16)) + ) + } + + private func providerIcon(for _: LanguageProviderDescriptor) -> some View { + LitheIcon(kind: .generic, size: 17) + } + + private func color(for severity: DiagnosticSeverity) -> Color { + switch severity { + case .error: LitheTheme.error + case .warning: LitheTheme.warning + case .information: LitheTheme.accent + case .hint: LitheTheme.secondaryText + } + } + + private func statusColor(_ status: LSPServerStatus) -> Color { + switch status { + case .active: LitheTheme.success + case .indexing: LitheTheme.warning + case .available: LitheTheme.accent + case .stopped: LitheTheme.secondaryText + case .error: LitheTheme.error + } + } + + private var languageServerDescriptors: [LanguageProviderDescriptor] { + model.languageProviderCatalog.descriptors + .filter { $0.capabilities.contains(.languageServer) } + } + + private var selectedDescriptor: LanguageProviderDescriptor? { + if let document = model.activeDocument, + let descriptor = model.languageProviderCatalog.provider(for: document.url), + descriptor.capabilities.contains(.languageServer) { + return descriptor + } + return languageServerDescriptors.first + } + + private var activeServerCount: Int { + languageServerDescriptors.filter { providerMetrics(for: $0).status == .active }.count + } + + private var projectLSPConfigPath: String { + guard let workspaceURL = model.workspaceURL else { + return ".lithe/lsp/language-providers.json" + } + return workspaceURL + .appendingPathComponent(".lithe") + .appendingPathComponent("lsp") + .appendingPathComponent("language-providers.json") + .path + } + + private var allDiagnostics: [EditorDiagnostic] { + model.editorDiagnostics.values + .flatMap { $0 } + .sorted { + if $0.severity != $1.severity { return $0.severity.sortOrder < $1.severity.sortOrder } + if $0.line != $1.line { return $0.line < $1.line } + return $0.message < $1.message + } + } + + private func providerMetrics(for descriptor: LanguageProviderDescriptor) -> LSPProviderMetrics { + let files = model.projectFiles.filter { descriptor.handles(fileURL: $0) } + let openFiles = model.openDocuments.filter { descriptor.handles(fileURL: $0.url) } + let diagnostics = model.editorDiagnostics + .filter { descriptor.handles(fileURL: $0.key) } + .values + .flatMap { $0 } + let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] ?? [] + let status: LSPServerStatus + if diagnostics.contains(where: { $0.severity == .error }) { + status = .error + } else if model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) || !features.isEmpty { + status = .active + } else if !openFiles.isEmpty { + status = .indexing + } else if !files.isEmpty { + status = .available + } else { + status = .stopped + } + + let totalDiagnostics = max(diagnostics.count, 1) + let errorCount = diagnostics.filter { $0.severity == .error }.count + let warningCount = diagnostics.filter { $0.severity == .warning }.count + let indexProgress = files.isEmpty ? 0 : min(1, Double(openFiles.count == 0 ? files.count / 2 : files.count) / Double(max(files.count, 1))) + + return LSPProviderMetrics( + status: status, + subtitle: files.isEmpty ? copy.noMatchingFiles : copy.matchingFiles(files.count), + workspacePath: model.workspaceURL?.path ?? copy.noWorkspace, + version: copy.providerVersion, + fileCount: files.count, + openFileCount: openFiles.count, + diagnosticCount: diagnostics.count, + errorCount: errorCount, + warningCount: warningCount, + featureCount: features.enabledFeatureCount, + featureProgress: Double(features.enabledFeatureCount) / 11.0, + indexProgress: indexProgress, + errorProgress: Double(errorCount) / Double(totalDiagnostics), + warningProgress: Double(warningCount) / Double(totalDiagnostics) + ) + } + +} + +private enum LSPServerStatus { + case active + case indexing + case available + case stopped + case error +} + +private struct LSPControlCenterCopy { + let language: AppLanguage + + private var usesChinese: Bool { + language == .simplifiedChinese + } + + var title: String { usesChinese ? "LSP 控制中心" : "LSP Control Center" } + var hideControlCenter: String { usesChinese ? "隐藏 LSP 控制中心" : "Hide LSP Control Center" } + var currentProject: String { usesChinese ? "当前项目:" : "Current project:" } + var lspActive: String { usesChinese ? "LSP 运行中" : "LSP active" } + var onDemand: String { usesChinese ? "按需启动" : "On demand" } + var restartAll: String { usesChinese ? "全部重启" : "Restart all" } + var clearDiagnostics: String { usesChinese ? "清空诊断" : "Clear diagnostics" } + var languageServers: String { usesChinese ? "语言服务器" : "Language Servers" } + var rootFiles: String { usesChinese ? "项目文件" : "Root files" } + var openFiles: String { usesChinese ? "打开文件" : "Open files" } + var diagnostics: String { usesChinese ? "诊断" : "Diagnostics" } + var features: String { usesChinese ? "功能" : "Features" } + var indexed: String { usesChinese ? "索引" : "Indexed" } + var errors: String { usesChinese ? "错误" : "Errors" } + var warnings: String { usesChinese ? "警告" : "Warnings" } + var openSupportedFile: String { usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" } + var matchingServerWillAppear: String { + usesChinese ? "匹配的语言服务器会显示在这里。" : "The matching language server will appear here." + } + var noLanguageServerDiagnostics: String { + usesChinese ? "暂无语言服务器诊断。" : "No language server diagnostics." + } + var capabilities: String { usesChinese ? "能力" : "Capabilities" } + var definition: String { usesChinese ? "定义" : "Definition" } + var completion: String { usesChinese ? "补全" : "Completion" } + var formatting: String { usesChinese ? "格式化" : "Formatting" } + var testing: String { usesChinese ? "测试" : "Testing" } + var debug: String { usesChinese ? "调试" : "Debug" } + var run: String { usesChinese ? "运行" : "Run" } + var noMatchingFiles: String { usesChinese ? "没有匹配文件" : "No matching files" } + var noWorkspace: String { usesChinese ? "未打开工作区" : "No workspace" } + var providerVersion: String { usesChinese ? "兼容层" : "provider" } + var providerConfiguration: String { usesChinese ? "Provider 配置" : "Provider Configuration" } + var rustOwnedConfiguration: String { + usesChinese ? "由 Rust LSP 配置加载" : "Loaded by Rust LSP configuration" + } + var providerID: String { usesChinese ? "Provider ID" : "Provider ID" } + var activation: String { usesChinese ? "启动策略" : "Activation" } + var builtinCatalog: String { usesChinese ? "内置 JSON" : "Built-in JSON" } + var projectOverride: String { usesChinese ? "项目覆盖" : "Project override" } + var configurationHint: String { + usesChinese + ? "语言、扩展名、能力、命令和平台覆盖只允许写入独立 LSP JSON,由 Rust 兼容层注册。" + : "Languages, extensions, capabilities, commands, and platform overrides belong in standalone LSP JSON registered by Rust." + } + + func matchingFiles(_ count: Int) -> String { + if usesChinese { return "\(count) 个匹配文件" } + return "\(count) matching file\(count == 1 ? "" : "s")" + } + + func activationPolicy(_ policy: ToolingActivationPolicy) -> String { + switch policy { + case .always: + usesChinese ? "始终启动" : "Always" + case .onDemand: + usesChinese ? "按需启动" : "On demand" + } + } + + func stopProvider(_ name: String) -> String { + usesChinese ? "停止 \(name)" : "Stop \(name)" + } + + func providerStartsOnDemand(_ name: String) -> String { + usesChinese + ? "\(name) 会在打开匹配文件时启动" + : "\(name) starts when a matching file opens" + } + + func title(for status: LSPServerStatus) -> String { + if usesChinese { + switch status { + case .active: "运行中" + case .indexing: "索引中" + case .available: "可用" + case .stopped: "已停止" + case .error: "错误" + } + } else { + switch status { + case .active: "Running" + case .indexing: "Indexing" + case .available: "Available" + case .stopped: "Stopped" + case .error: "Error" + } + } + } +} + +private struct LSPProviderMetrics { + let status: LSPServerStatus + let subtitle: String + let workspacePath: String + let version: String + let fileCount: Int + let openFileCount: Int + let diagnosticCount: Int + let errorCount: Int + let warningCount: Int + let featureCount: Int + let featureProgress: Double + let indexProgress: Double + let errorProgress: Double + let warningProgress: Double + + var indexProgressText: String { + "\(Int((indexProgress * 100).rounded()))%" + } +} + +private extension DiagnosticSeverity { + var sortOrder: Int { + switch self { + case .error: 0 + case .warning: 1 + case .information: 2 + case .hint: 3 + } + } +} + +private extension LanguageServerFeatureSet { + var enabledFeatureCount: Int { + [ + .definition, + .references, + .implementation, + .hover, + .completion, + .rename, + .formatting, + .codeActions, + .completionResolve, + .codeActionResolve, + .executeCommand + ].filter { contains($0) }.count + } +} + +private extension View { + func panelChrome() -> some View { + background( + RoundedRectangle(cornerRadius: 8) + .fill(LitheTheme.sidebar) + ) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } +} diff --git a/Sources/Lithe/Views/WorkbenchView.swift b/Sources/Lithe/Views/WorkbenchView.swift index c8fda6c9..adcf92c5 100644 --- a/Sources/Lithe/Views/WorkbenchView.swift +++ b/Sources/Lithe/Views/WorkbenchView.swift @@ -8,6 +8,8 @@ struct WorkbenchView: View { @EnvironmentObject private var runFeature: RunFeatureModel @State private var sidebarWidth: CGFloat = 320 @State private var sidebarDragStart: CGFloat = 320 + @State private var lspPanelWidth: CGFloat = 420 + @State private var lspPanelDragStart: CGFloat = 420 @State private var topPaneHeight: CGFloat? @State private var topPaneDragStart: CGFloat = 0 @State private var isBranchSwitcherPresented = false @@ -596,6 +598,14 @@ struct WorkbenchView: View { model.toggleTests() } + activityToolButton( + systemImage: "server.rack", + help: lspControlCenterTitle, + isSelected: model.isLSPControlCenterVisible + ) { + model.isLSPControlCenterVisible.toggle() + } + activityToolButton( systemImage: "ladybug", ideaAssetPath: "toolwindows/toolWindowDebugger.svg", @@ -710,6 +720,10 @@ struct WorkbenchView: View { } } + private var lspControlCenterTitle: String { + settings.language == .simplifiedChinese ? "LSP 控制中心" : "LSP Control Center" + } + private var runConfigurationSetupTitle: String { switch runFeature.configurationStatus { case .missing: @@ -792,9 +806,18 @@ struct WorkbenchView: View { let availableTopWidth = max(0, geometry.size.width - (horizontalPadding * 2)) let minimumSidebarWidth: CGFloat = 220 let minimumEditorWidth: CGFloat = 400 + let minimumLSPPanelWidth: CGFloat = 320 + let lspPanelHandleWidth = model.isLSPControlCenterVisible ? SplitHandleView.thickness : 0 + let resolvedLSPPanelWidth = model.isLSPControlCenterVisible + ? constrained( + lspPanelWidth, + minimum: minimumLSPPanelWidth, + maximum: max(minimumLSPPanelWidth, min(540, availableTopWidth - SplitHandleView.thickness - minimumSidebarWidth - SplitHandleView.thickness - minimumEditorWidth)) + ) + : 0 let maximumSidebarWidth = max( minimumSidebarWidth, - min(520, availableTopWidth - SplitHandleView.thickness - minimumEditorWidth) + min(520, availableTopWidth - SplitHandleView.thickness - minimumEditorWidth - lspPanelHandleWidth - resolvedLSPPanelWidth) ) let resolvedSidebarWidth = constrained( sidebarWidth, @@ -836,6 +859,27 @@ struct WorkbenchView: View { EditorAreaView() .clipShape(RoundedRectangle(cornerRadius: 10)) + + if model.isLSPControlCenterVisible { + SplitHandleView( + axis: .horizontal, + onDragStarted: { + lspPanelDragStart = resolvedLSPPanelWidth + }, + onDragChanged: { translation in + lspPanelWidth = constrained( + lspPanelDragStart - translation, + minimum: minimumLSPPanelWidth, + maximum: max(minimumLSPPanelWidth, min(540, availableTopWidth - SplitHandleView.thickness - minimumSidebarWidth - SplitHandleView.thickness - minimumEditorWidth)) + ) + }, + onDragEnded: {} + ) + + LSPControlCenterView() + .frame(width: resolvedLSPPanelWidth) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } } .padding(.top, 6) .padding(.horizontal, 6) @@ -953,24 +997,7 @@ struct WorkbenchView: View { ) { model.selectedSidebar = .project } - if index < components.count - 1 || !activeJavaBreadcrumbs.isEmpty { - breadcrumbSeparator - } - } - - ForEach(Array(activeJavaBreadcrumbs.enumerated()), id: \.offset) { index, hint in - breadcrumbItem( - title: hint.symbol, - iconKind: index == activeJavaBreadcrumbs.count - 1 ? .javaGeneric : .javaClass, - isEmphasized: index == activeJavaBreadcrumbs.count - 1 - ) { - model.editorNavigationTarget = EditorNavigationTarget( - url: document.url, - line: hint.line, - utf16Column: hint.utf16Column - ) - } - if index < activeJavaBreadcrumbs.count - 1 { + if index < components.count - 1 { breadcrumbSeparator } } @@ -984,19 +1011,6 @@ struct WorkbenchView: View { } } - private var activeJavaBreadcrumbs: [JavaCodeVisionHint] { - guard let document = model.activeDocument, - document.url.pathExtension.lowercased() == "java" else { return [] } - let caretLine = model.editorCaret?.url.standardizedFileURL == document.url.standardizedFileURL - ? model.editorCaret?.line ?? Int.max - : Int.max - return Array( - (model.javaCodeVisionHints[document.url] ?? []) - .filter { $0.line <= caretLine } - .suffix(2) - ) - } - private func breadcrumbItem( title: String, iconKind: LitheIconKind?, diff --git a/Sources/LitheRustCore/bridge.c b/Sources/LitheRustCore/bridge.c index e85dfe38..9ff230bb 100644 --- a/Sources/LitheRustCore/bridge.c +++ b/Sources/LitheRustCore/bridge.c @@ -11,6 +11,11 @@ __attribute__((weak)) char *lithe_core_execute_json(const char *request) { return NULL; } +__attribute__((weak)) char *lithe_core_lsp_provider_catalog_json(const char *workspace_root) { + (void)workspace_root; + return NULL; +} + __attribute__((weak)) int32_t lithe_core_cancel(const char *operation_id) { (void)operation_id; return 0; @@ -28,6 +33,10 @@ char *lithe_bridge_execute_json(const char *request) { return lithe_core_execute_json(request); } +char *lithe_bridge_lsp_provider_catalog_json(const char *workspace_root) { + return lithe_core_lsp_provider_catalog_json(workspace_root); +} + int32_t lithe_bridge_cancel(const char *operation_id) { return lithe_core_cancel(operation_id); } diff --git a/Sources/LitheRustCore/include/lithe_bridge.h b/Sources/LitheRustCore/include/lithe_bridge.h index 8f7796e2..da17a658 100644 --- a/Sources/LitheRustCore/include/lithe_bridge.h +++ b/Sources/LitheRustCore/include/lithe_bridge.h @@ -9,6 +9,7 @@ extern "C" { const char *lithe_bridge_version(void); char *lithe_bridge_execute_json(const char *request); +char *lithe_bridge_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_bridge_cancel(const char *operation_id); void lithe_bridge_free_string(char *value); diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index eaad6075..08461251 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -83,24 +83,33 @@ struct RunConfigurationIntegrationTests { } @Test - func languageProviderCatalogIsOnDemandAndDescribesCrossLanguageCapabilities() { + func languageProviderCatalogKeepsOnlyCompatibilityFallbackInSwiftWhenRustCoreIsUnavailable() { let catalog = LanguageProviderCatalog.standard let go = catalog.provider(for: URL(fileURLWithPath: "/tmp/cmd/main.go")) let python = catalog.provider(for: URL(fileURLWithPath: "/tmp/api/server.py")) + let node = catalog.provider(for: URL(fileURLWithPath: "/tmp/web/App.tsx")) #expect(go?.id == "go") #expect(python?.id == "python") + #expect(node?.id == "node") + #expect(node?.languageIdentifier(for: URL(fileURLWithPath: "/tmp/web/App.tsx")) == "typescriptreact") #expect(go?.activationPolicy == .onDemand) #expect(go?.capabilities.contains(.languageServer) == true) #expect(go?.capabilities.contains(.debugAdapter) == true) #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))?.capabilities.contains(.debugAdapter) == false) + if !RustCoreBridge().isAvailable { + #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Package.swift")) == nil) + #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil) + } } @Test func standardLanguagePackRegistryDerivesAllFocusedRegistries() { let registry = LanguagePackRegistry.standard() + let providerIDs = registry.packs.map(\.descriptor.id) + let providerIDSet = Set(providerIDs) - #expect(registry.packs.map { $0.descriptor.id } == ["java", "go", "python", "node", "rust"]) + #expect(providerIDs.starts(with: ["java", "go", "python", "node", "rust"])) #expect(registry.catalog.provider(for: URL(fileURLWithPath: "/tmp/main.go"))?.id == "go") #expect(registry.runProviders.provider(for: URL(fileURLWithPath: "/tmp/main.py"))?.descriptor.id == "python") #expect(registry.testProviders.provider(id: "python")?.descriptor.id == "python") @@ -111,11 +120,14 @@ struct RunConfigurationIntegrationTests { #expect(registry.pack(id: "go")?.toolchainProviders.contains { $0.languageProviderID == "go" && $0.identifiers.contains("project-go") } == true) - #expect(registry.pack(id: "go")?.languageServerLaunch?.executableNames == ["gopls"]) #expect(registry.pack(id: "go")?.debugAdapterLaunch?.executableNames == ["dlv"]) #expect(registry.pack(id: "python")?.debugAdapterLaunch?.adapterID == "python") #expect(registry.pack(id: "rust")?.debugAdapterLaunch?.fallbacks.first?.executableName == "xcrun") #expect(registry.pack(id: "java")?.debugAdapterLaunch?.adapterID == "java") + if !RustCoreBridge().isAvailable { + #expect(providerIDSet == ["java", "go", "python", "node", "rust"]) + #expect(registry.catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil) + } } @Test @@ -158,7 +170,7 @@ struct RunConfigurationIntegrationTests { }] ).first { $0.descriptor.id == "node" }) let javaDescriptor = try #require(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))) - let javaRuntime = TestLanguageProviderRuntime(descriptor: javaDescriptor) + let javaRuntime = TestDebugLanguageProviderRuntime(descriptor: javaDescriptor) let manager = LanguageToolingSessionManager( catalog: catalog, runtimes: [nodeRuntime, javaRuntime] @@ -172,8 +184,14 @@ struct RunConfigurationIntegrationTests { @Test func aFutureJavaDAPRuntimeCanOverrideTheLegacyDebugBoundary() throws { - let catalog = LanguageProviderCatalog.standard - let javaDescriptor = try #require(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))) + let javaDescriptor = LanguageProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"], + capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], + activationPolicy: .onDemand + ) + let catalog = LanguageProviderCatalog(descriptors: [javaDescriptor]) let runtime = TestDebugLanguageProviderRuntime( descriptor: javaDescriptor, supportsDebugAdapter: true @@ -217,9 +235,9 @@ struct RunConfigurationIntegrationTests { func macToolDiscoveryReportsProjectHomebrewAndXcodeSources() { let root = URL(fileURLWithPath: "/tmp/mac-tool-project", isDirectory: true) let executablePaths: Set = [ - root.appendingPathComponent(".lithe/toolchains/bin/gopls").path, - "/custom/bin/gopls", - "/opt/homebrew/bin/gopls", + root.appendingPathComponent(".lithe/toolchains/bin/dlv").path, + "/custom/bin/dlv", + "/opt/homebrew/bin/dlv", "/Library/Developer/CommandLineTools/usr/bin/lldb-dap" ] let discovery = MacRuntimeToolDiscovery( @@ -228,15 +246,15 @@ struct RunConfigurationIntegrationTests { ) let goCandidates = discovery.candidates( - for: "gopls", + for: "dlv", projectURL: root, environment: ["PATH": "/custom/bin"] ) #expect(goCandidates.map(\.source) == [.project, .path, .homebrew]) #expect(goCandidates.map(\.executableURL.path) == [ - root.appendingPathComponent(".lithe/toolchains/bin/gopls").path, - "/custom/bin/gopls", - "/opt/homebrew/bin/gopls" + root.appendingPathComponent(".lithe/toolchains/bin/dlv").path, + "/custom/bin/dlv", + "/opt/homebrew/bin/dlv" ]) let lldbCandidates = discovery.candidates( @@ -743,7 +761,7 @@ struct RunConfigurationIntegrationTests { endUTF16Column: 8, severity: .warning, message: "Java warning", - source: "jdtls", + source: "java", code: "java-warning", tags: [], relatedInformation: [] @@ -783,7 +801,7 @@ struct RunConfigurationIntegrationTests { ), severity: 1, message: "Cannot resolve symbol", - source: "jdtls", + source: "java", code: "resolve" ) let existing = EditorDiagnostic(languageServerDiagnostic: lsp, fileURL: fileURL) @@ -928,66 +946,47 @@ struct RunConfigurationIntegrationTests { } @Test - func toolingSessionsStartLazilyAndReuseOneSessionPerProvider() throws { + func languageToolingSessionsKeepSwiftLSPAtTheRustHostBoundary() throws { let descriptor = try #require(LanguageProviderCatalog.standard.provider( for: URL(fileURLWithPath: "/tmp/main.go") )) - let runtime = TestLanguageProviderRuntime(descriptor: descriptor) - let manager = LanguageToolingSessionManager( - catalog: .standard, - runtimes: [runtime] - ) - #expect(manager.activeLanguageServerIDs.isEmpty) - - let first = try manager.activateLanguageServer( - for: URL(fileURLWithPath: "/tmp/main.go"), - rootURL: URL(fileURLWithPath: "/tmp/project") + let runtimeService = ProjectRuntimeService( + runtimeLocator: RunTestRuntimeLocator(), + store: RunTestKeyValueStore() ) - let second = try manager.activateLanguageServer( - for: URL(fileURLWithPath: "/tmp/other.go"), - rootURL: URL(fileURLWithPath: "/tmp/project") + let process = RecordingRawProcessSession() + let runtime = StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + processFactory: { process } ) + let manager = LanguageToolingSessionManager(runtimes: [runtime]) + let root = URL(fileURLWithPath: "/tmp/go-project", isDirectory: true) + let source = root.appendingPathComponent("main.go") - _ = first - _ = second - #expect(runtime.makeCount == 1) - #expect(runtime.languageServer.startCount == 1) - #expect(manager.activeLanguageServerIDs == ["go"]) - - manager.stopAll() - #expect(runtime.languageServer.stopCount == 1) #expect(manager.activeLanguageServerIDs.isEmpty) - } - - @Test - func toolingSessionsReplaceExitedOrWrongWorkspaceProcesses() throws { - let descriptor = try #require(LanguageProviderCatalog.standard.provider( - for: URL(fileURLWithPath: "/tmp/main.go") - )) - let runtime = TestLanguageProviderRuntime(descriptor: descriptor) - let manager = LanguageToolingSessionManager(catalog: .standard, runtimes: [runtime]) - let firstRoot = URL(fileURLWithPath: "/tmp/first-project") - let secondRoot = URL(fileURLWithPath: "/tmp/second-project") - - let first = try manager.activateLanguageServer( - for: firstRoot.appendingPathComponent("main.go"), - rootURL: firstRoot - ) - first.stop() - let restarted = try manager.activateLanguageServer( - for: firstRoot.appendingPathComponent("other.go"), - rootURL: firstRoot - ) - let moved = try manager.activateLanguageServer( - for: secondRoot.appendingPathComponent("main.go"), - rootURL: secondRoot + #expect(manager.features(for: source).isEmpty) + #expect(!manager.supportsGenericEditing(for: source)) + try manager.synchronizeLanguageServer( + for: source, + text: "package main\nfunc main() {}\n", + rootURL: root ) + #expect(process.requests.isEmpty) + #expect(process.sentData.isEmpty) - #expect(first !== restarted) - #expect(restarted !== moved) - #expect(runtime.makeCount == 3) - #expect(runtime.languageServers.map(\.startCount) == [1, 1, 1]) - #expect(runtime.languageServers[1].stopCount == 1) + #expect(throws: LanguageToolingSessionError.self) { + try manager.hover( + fileURL: source, + text: "package main\n", + position: LanguageServerPosition(line: 0, utf16Column: 0), + rootURL: root + ) { _ in } + } + #expect(throws: LanguageToolingSessionError.self) { + try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in } + } + #expect(manager.activeLanguageServerIDs.isEmpty) } @Test @@ -1014,497 +1013,6 @@ struct RunConfigurationIntegrationTests { #expect(runtime.debugAdapters.last?.breakpointUpdates.isEmpty == true) } - @Test - func stdioLanguageProvidersInitializeOnlyWhenActivated() async throws { - let descriptor = try #require(LanguageProviderCatalog.standard.provider( - for: URL(fileURLWithPath: "/tmp/main.go") - )) - let runtimeService = ProjectRuntimeService( - runtimeLocator: RunTestRuntimeLocator(), - store: RunTestKeyValueStore() - ) - let process = RecordingRawProcessSession() - let runtime = StdioLanguageProviderRuntime( - descriptor: descriptor, - runtimeService: runtimeService, - processFactory: { process }, - launch: StdioLanguageServerLaunch(executableNames: ["gopls"], arguments: []) - ) - let manager = LanguageToolingSessionManager(runtimes: [runtime]) - let root = URL(fileURLWithPath: "/tmp/go-project", isDirectory: true) - - #expect(process.requests.isEmpty) - #expect(manager.supportsGenericEditing(for: root.appendingPathComponent("main.go"))) - #expect(process.sentData.isEmpty) - let session = try manager.activateLanguageServer( - for: root.appendingPathComponent("main.go"), - rootURL: root - ) - let request = try #require(process.requests.first) - #expect(request.executablePath == "/usr/bin/gopls") - #expect(request.keepsStandardInputOpen) - let initialize = try #require(Self.framedJSON(process.sentData.first)) - #expect(initialize["method"] as? String == "initialize") - #expect(initialize["id"] as? Int == 1) - #expect(!session.isReady) - try manager.synchronizeLanguageServer( - for: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() {}\n", - rootURL: root - ) - - process.emitJSON([ - "jsonrpc": "2.0", - "id": 1, - "result": ["capabilities": [ - "definitionProvider": true, - "referencesProvider": true, - "implementationProvider": true, - "hoverProvider": true, - "completionProvider": ["resolveProvider": true], - "renameProvider": ["prepareProvider": true], - "documentFormattingProvider": true, - "codeActionProvider": ["resolveProvider": true], - "executeCommandProvider": ["commands": ["gopls.organizeImports"]] - ]] - ], splitAt: 12) - await Task.yield() - await Task.yield() - - #expect(session.isReady) - let features = manager.features(for: root.appendingPathComponent("main.go")) - #expect(features.contains([.definition, .completion, .completionResolve])) - #expect(features.contains([.rename, .formatting, .codeActions, .codeActionResolve])) - let initialized = try #require(Self.framedJSON(process.sentData.dropFirst().first)) - #expect(initialized["method"] as? String == "initialized") - let didOpen = try #require(Self.framedJSON(process.sentData.last)) - #expect(didOpen["method"] as? String == "textDocument/didOpen") - let openParameters = try #require(didOpen["params"] as? [String: Any]) - let openedDocument = try #require(openParameters["textDocument"] as? [String: Any]) - #expect(openedDocument["languageId"] as? String == "go") - - try manager.synchronizeLanguageServer( - for: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken }\n", - rootURL: root - ) - let didChange = try #require(Self.framedJSON(process.sentData.last)) - #expect(didChange["method"] as? String == "textDocument/didChange") - let changeParameters = try #require(didChange["params"] as? [String: Any]) - let changedDocument = try #require(changeParameters["textDocument"] as? [String: Any]) - #expect(changedDocument["version"] as? Int == 2) - - process.emitJSON([ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": [ - "uri": root.appendingPathComponent("main.go").absoluteString, - "diagnostics": [[ - "range": [ - "start": ["line": 1, "character": 14], - "end": ["line": 1, "character": 20] - ], - "severity": 1, - "source": "gopls", - "message": "undefined: broken" - ]] - ] - ]) - await Task.yield() - await Task.yield() - let diagnostics = manager.diagnostics[root.appendingPathComponent("main.go").standardizedFileURL] - #expect(diagnostics?.first?.message == "undefined: broken") - - var navigationResult: Result<[LanguageServerLocation], Error>? - try manager.navigate( - method: "textDocument/definition", - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken }\n", - position: LanguageServerPosition(line: 1, utf16Column: 16), - rootURL: root - ) { navigationResult = $0 } - let definitionRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(definitionRequest["method"] as? String == "textDocument/definition") - let definitionID = try #require(definitionRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", - "id": definitionID, - "result": [[ - "targetUri": root.appendingPathComponent("helper.go").absoluteString, - "targetRange": [ - "start": ["line": 4, "character": 0], - "end": ["line": 4, "character": 6] - ], - "targetSelectionRange": [ - "start": ["line": 4, "character": 2], - "end": ["line": 4, "character": 6] - ] - ]] - ]) - await Self.drainMainActorTasks() - let definition = try #require(try navigationResult?.get().first) - #expect(definition.url == root.appendingPathComponent("helper.go").standardizedFileURL) - #expect(definition.range.start == LanguageServerPosition(line: 4, utf16Column: 2)) - - var hoverResult: Result? - try manager.hover( - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken }\n", - position: LanguageServerPosition(line: 1, utf16Column: 16), - rootURL: root - ) { hoverResult = $0 } - let hoverRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(hoverRequest["method"] as? String == "textDocument/hover") - let hoverID = try #require(hoverRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", - "id": hoverID, - "result": [ - "contents": ["kind": "markdown", "value": "```go\nfunc broken()\n```"], - "range": [ - "start": ["line": 1, "character": 14], - "end": ["line": 1, "character": 20] - ] - ] - ]) - await Self.drainMainActorTasks() - let hover = try #require(try hoverResult?.get()) - #expect(hover.isMarkdown) - #expect(hover.contents.contains("func broken()")) - #expect(hover.range?.start == LanguageServerPosition(line: 1, utf16Column: 14)) - - var completionResult: Result<[LanguageServerCompletionItem], Error>? - try manager.completions( - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { bro }\n", - position: LanguageServerPosition(line: 1, utf16Column: 17), - rootURL: root - ) { completionResult = $0 } - let completionRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(completionRequest["method"] as? String == "textDocument/completion") - let completionID = try #require(completionRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", - "id": completionID, - "result": [ - "isIncomplete": false, - "items": [ - [ - "label": "brokenValue", - "detail": "var brokenValue int", - "documentation": ["kind": "markdown", "value": "A test value"], - "insertText": "brokenValue", - "sortText": "2" - ], - [ - "label": "broken", - "textEdit": [ - "range": [ - "start": ["line": 1, "character": 14], - "end": ["line": 1, "character": 17] - ], - "newText": "broken()" - ], - "additionalTextEdits": [[ - "range": [ - "start": ["line": 1, "character": 0], - "end": ["line": 1, "character": 0] - ], - "newText": "// imported\n" - ]], - "data": ["token": 17, "lazy": true], - "sortText": "1" - ] - ] - ] - ]) - await Self.drainMainActorTasks() - let completions = try #require(try completionResult?.get()) - #expect(completions.map(\.label) == ["broken", "brokenValue"]) - #expect(completions.first?.insertText == "broken()") - #expect(completions.first?.textEdit?.range.start.utf16Column == 14) - #expect(completions.first?.additionalTextEdits.first?.newText == "// imported\n") - #expect(completions.first?.data == .object(["token": .integer(17), "lazy": .bool(true)])) - #expect(completions.last?.documentation == "A test value") - - let unresolvedCompletion = try #require(completions.first) - var resolvedCompletionResult: Result? - try manager.resolveCompletion( - unresolvedCompletion, - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { bro }\n", - rootURL: root - ) { resolvedCompletionResult = $0 } - let resolveCompletionRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(resolveCompletionRequest["method"] as? String == "completionItem/resolve") - let resolveCompletionID = try #require(resolveCompletionRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", "id": resolveCompletionID, - "result": [ - "label": "broken", - "detail": "resolved function", - "textEdit": [ - "range": [ - "start": ["line": 1, "character": 14], - "end": ["line": 1, "character": 17] - ], - "newText": "broken()" - ], - "data": ["token": 17, "lazy": true] - ] - ]) - await Self.drainMainActorTasks() - #expect(try resolvedCompletionResult?.get().detail == "resolved function") - - var renameResult: Result? - try manager.rename( - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken() }\n", - position: LanguageServerPosition(line: 1, utf16Column: 19), - newName: "fixed", - rootURL: root - ) { renameResult = $0 } - let renameRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(renameRequest["method"] as? String == "textDocument/rename") - let renameID = try #require(renameRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", "id": renameID, - "result": ["changes": [ - root.appendingPathComponent("main.go").absoluteString: [[ - "range": [ - "start": ["line": 1, "character": 17], - "end": ["line": 1, "character": 23] - ], - "newText": "fixed" - ]] - ]] - ]) - await Self.drainMainActorTasks() - let rename = try #require(try renameResult?.get()) - #expect(rename.changes[root.appendingPathComponent("main.go").standardizedFileURL]?.first?.newText == "fixed") - - var formattingResult: Result<[LanguageServerTextEdit], Error>? - try manager.format( - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken() }\n", - rootURL: root - ) { formattingResult = $0 } - let formattingRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(formattingRequest["method"] as? String == "textDocument/formatting") - let formattingID = try #require(formattingRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", "id": formattingID, - "result": [[ - "range": [ - "start": ["line": 1, "character": 0], - "end": ["line": 1, "character": 29] - ], - "newText": "func main() {\n\tbroken()\n}" - ]] - ]) - await Self.drainMainActorTasks() - #expect(try formattingResult?.get().first?.newText.contains("broken()") == true) - - var actionsResult: Result<[LanguageServerCodeAction], Error>? - try manager.codeActions( - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken() }\n", - range: LanguageServerRange( - start: LanguageServerPosition(line: 1, utf16Column: 17), - end: LanguageServerPosition(line: 1, utf16Column: 23) - ), - diagnostics: [LanguageServerDiagnostic( - range: LanguageServerRange( - start: LanguageServerPosition(line: 1, utf16Column: 17), - end: LanguageServerPosition(line: 1, utf16Column: 23) - ), - severity: 1, - message: "undefined: broken", - source: "gopls", - code: nil - )], - rootURL: root - ) { actionsResult = $0 } - let actionsRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(actionsRequest["method"] as? String == "textDocument/codeAction") - let actionsID = try #require(actionsRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", "id": actionsID, - "result": [[ - "title": "Fix symbol", - "kind": "quickfix", - "isPreferred": true, - "edit": ["changes": [ - root.appendingPathComponent("main.go").absoluteString: [] - ]], - "command": [ - "title": "Organize imports", - "command": "gopls.organizeImports", - "arguments": [["uri": root.appendingPathComponent("main.go").absoluteString]] - ], - "data": ["action": 4] - ]] - ]) - await Self.drainMainActorTasks() - let action = try #require(try actionsResult?.get().first) - #expect(action.isPreferred) - #expect(action.data == .object(["action": .integer(4)])) - - var resolvedActionResult: Result? - try manager.resolveCodeAction( - action, - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken() }\n", - rootURL: root - ) { resolvedActionResult = $0 } - let resolveActionRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(resolveActionRequest["method"] as? String == "codeAction/resolve") - let resolveActionID = try #require(resolveActionRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", "id": resolveActionID, - "result": [ - "title": "Fix symbol", - "kind": "quickfix", - "command": [ - "title": "Organize imports", - "command": "gopls.organizeImports", - "arguments": [["uri": root.appendingPathComponent("main.go").absoluteString]] - ], - "data": ["action": 4] - ] - ]) - await Self.drainMainActorTasks() - let resolvedAction = try #require(try resolvedActionResult?.get()) - let actionCommand = try #require(resolvedAction.command) - #expect(actionCommand.command == "gopls.organizeImports") - #expect(actionCommand.arguments.first == .object([ - "uri": .string(root.appendingPathComponent("main.go").absoluteString) - ])) - - var commandResult: Result? - try manager.execute( - actionCommand, - fileURL: root.appendingPathComponent("main.go"), - text: "package main\nfunc main() { broken() }\n", - rootURL: root - ) { commandResult = $0 } - let commandRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(commandRequest["method"] as? String == "workspace/executeCommand") - let commandID = try #require(commandRequest["id"] as? Int) - process.emitJSON(["jsonrpc": "2.0", "id": commandID, "result": NSNull()]) - await Self.drainMainActorTasks() - #expect(commandResult != nil) - _ = try commandResult?.get() - - manager.closeDocument(root.appendingPathComponent("main.go")) - let didClose = try #require(Self.framedJSON(process.sentData.last)) - #expect(didClose["method"] as? String == "textDocument/didClose") - #expect(manager.diagnostics[root.appendingPathComponent("main.go").standardizedFileURL] == nil) - } - - @Test - func languageServerDynamicRegistrationsUpdateFeaturesAndRequestGuards() async throws { - let source = URL(fileURLWithPath: "/tmp/dynamic-go-project/main.go") - let root = source.deletingLastPathComponent() - let descriptor = try #require(LanguageProviderCatalog.standard.provider(for: source)) - let process = RecordingRawProcessSession() - let runtime = StdioLanguageProviderRuntime( - descriptor: descriptor, - runtimeService: ProjectRuntimeService( - runtimeLocator: RunTestRuntimeLocator(), - store: RunTestKeyValueStore() - ), - processFactory: { process }, - launch: StdioLanguageServerLaunch(executableNames: ["gopls"], arguments: []) - ) - let manager = LanguageToolingSessionManager(runtimes: [runtime]) - - _ = try manager.activateLanguageServer(for: source, rootURL: root) - let initialize = try #require(Self.framedJSON(process.sentData.first)) - let capabilities = try #require(initialize["params"] as? [String: Any]) - let clientCapabilities = try #require(capabilities["capabilities"] as? [String: Any]) - let textDocument = try #require(clientCapabilities["textDocument"] as? [String: Any]) - let formatting = try #require(textDocument["formatting"] as? [String: Any]) - #expect(formatting["dynamicRegistration"] as? Bool == true) - let initializeID = try #require(initialize["id"] as? Int) - - var earlyFormattingResult: Result<[LanguageServerTextEdit], Error>? - try manager.format(fileURL: source, text: "package main\n", rootURL: root) { - earlyFormattingResult = $0 - } - #expect(process.sentData.count == 1) - - process.emitJSON([ - "jsonrpc": "2.0", - "id": initializeID, - "result": ["capabilities": ["definitionProvider": true]] - ]) - await Self.drainMainActorTasks() - #expect(manager.features(for: source) == .definition) - #expect(throws: (any Error).self) { - _ = try earlyFormattingResult?.get() - } - #expect(!process.sentData.compactMap(Self.framedJSON).contains { - $0["method"] as? String == "textDocument/formatting" - }) - - let countBeforeRejectedFormat = process.sentData.count - #expect(throws: LanguageToolingSessionError.self) { - try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in } - } - #expect(process.sentData.count == countBeforeRejectedFormat) - - process.emitJSON([ - "jsonrpc": "2.0", - "id": 70, - "method": "client/registerCapability", - "params": ["registrations": [ - [ - "id": "formatting-registration", - "method": "textDocument/formatting", - "registerOptions": [:] - ], - [ - "id": "completion-registration", - "method": "textDocument/completion", - "registerOptions": ["resolveProvider": true] - ] - ]] - ]) - await Self.drainMainActorTasks() - let registeredFeatures = manager.features(for: source) - #expect(registeredFeatures.contains([.definition, .formatting, .completion, .completionResolve])) - let registrationResponse = try #require(Self.framedJSON(process.sentData.last)) - #expect(registrationResponse["id"] as? Int == 70) - #expect(registrationResponse["result"] is NSNull) - - try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in } - let formattingRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(formattingRequest["method"] as? String == "textDocument/formatting") - - process.emitJSON([ - "jsonrpc": "2.0", - "id": 71, - "method": "client/unregisterCapability", - "params": ["unregisterations": [[ - "id": "formatting-registration", - "method": "textDocument/formatting" - ]]] - ]) - await Self.drainMainActorTasks() - let unregisteredFeatures = manager.features(for: source) - #expect(!unregisteredFeatures.contains(.formatting)) - #expect(unregisteredFeatures.contains([.definition, .completion, .completionResolve])) - let unregistrationResponse = try #require(Self.framedJSON(process.sentData.last)) - #expect(unregistrationResponse["id"] as? Int == 71) - - let countBeforeSecondRejectedFormat = process.sentData.count - #expect(throws: LanguageToolingSessionError.self) { - try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in } - } - #expect(process.sentData.count == countBeforeSecondRejectedFormat) - } - @Test func stdioDebugAdapterImplementsLaunchBreakpointsInspectionAndControl() async throws { let descriptor = try #require(LanguageProviderCatalog.standard.provider( @@ -1519,10 +1027,6 @@ struct RunConfigurationIntegrationTests { descriptor: descriptor, runtimeService: runtimeService, processFactory: { process }, - launch: StdioLanguageServerLaunch( - executableNames: ["pyright-langserver"], - arguments: ["--stdio"] - ), debugLaunch: StdioDebugAdapterLaunch( adapterID: "python", executableNames: ["python3"], @@ -1821,7 +1325,6 @@ struct RunConfigurationIntegrationTests { descriptor: descriptor, runtimeService: runtimeService, processFactory: { process }, - launch: StdioLanguageServerLaunch(executableNames: ["pyright-langserver"], arguments: ["--stdio"]), debugLaunch: StdioDebugAdapterLaunch( adapterID: "python", executableNames: ["python3"], @@ -2251,7 +1754,6 @@ struct RunConfigurationIntegrationTests { #expect(report.javaHomePath == "/toolchains/jdk") #expect(report.jdbExecutablePath == "/toolchains/jdk/bin/jdb") #expect(!report.status.blocksJavaRun) - #expect(!report.status.blocksJavaEditing) } @Test @@ -2267,185 +1769,27 @@ struct RunConfigurationIntegrationTests { let report = try #require(runtime.javaEnvironmentReport) #expect(report.status == .jdkMissing) #expect(report.status.blocksJavaRun) - #expect(report.status.blocksJavaEditing) #expect(report.recovery.contains("JAVA_HOME")) } @Test - func javaLanguageServerUsesTheSameProjectJDKWithSpaces() throws { - let root = URL(fileURLWithPath: "/tmp/lithe-language-toolchain", isDirectory: true) - let runtime = ProjectRuntimeService( - runtimeLocator: RunTestRuntimeLocator(), - store: RunTestKeyValueStore(), - toolchainSource: RecordingToolchainSource(selection: ProjectToolchainSelection( - javaHomePath: "/local/JDK 21", - mavenExecutablePath: "", - mavenJavaHomePath: "" - )) - ) - runtime.openProject(at: root) - let process = RecordingRawProcessSession() - let service = JavaLanguageService( - runtimeService: runtime, - process: process, - archiveReader: EmptyArchiveEntryReader(), - fileStorage: RunTestFileStorage(), - javaMavenOperations: RunTestJavaMavenOperations() - ) - - service.prepare(for: root) - - let request = try #require(process.requests.first) - #expect(request.executablePath == "/toolchains/jdtls") - #expect(request.arguments.contains("--java-executable")) - #expect(request.arguments.contains("/local/JDK 21/bin/java")) - } - - @Test - func javaLanguageServerParticipatesInTheGenericOnDemandLifecycle() async throws { - let root = URL(fileURLWithPath: "/tmp/lithe-generic-java-lsp", isDirectory: true) - let runtime = ProjectRuntimeService( - runtimeLocator: RunTestRuntimeLocator(), - store: RunTestKeyValueStore() - ) - runtime.openProject(at: root) - let process = RecordingRawProcessSession() - let service = JavaLanguageService( - runtimeService: runtime, - process: process, - archiveReader: EmptyArchiveEntryReader(), - fileStorage: RunTestFileStorage(), - javaMavenOperations: RunTestJavaMavenOperations() + func javaImplementationMarkersStayBehindTheRustLSPHostBoundary() async { + let service = JavaImplementationMarkerService() + let root = URL(fileURLWithPath: "/tmp/lithe-java-marker-boundary", isDirectory: true) + let document = EditorDocument( + url: root.appendingPathComponent("src/Main.java"), + text: "interface Service {}\nclass Impl implements Service {}\n", + modificationDate: nil ) - let provider = JavaLanguageProviderRuntime(service: service) - let manager = LanguageToolingSessionManager(runtimes: [provider]) - - #expect(process.requests.isEmpty) - let source = root.appendingPathComponent("src/Main.java") - #expect(manager.supportsGenericEditing(for: source)) - _ = try manager.activateLanguageServer( - for: source, - rootURL: root - ) - #expect(process.requests.count == 1) - #expect(manager.activeLanguageServerIDs == ["java"]) - - let initialize = try #require(Self.framedJSON(process.sentData.first)) - let initializeID = try #require(initialize["id"] as? Int) - let initializeParameters = try #require(initialize["params"] as? [String: Any]) - let clientCapabilities = try #require(initializeParameters["capabilities"] as? [String: Any]) - let textDocumentCapabilities = try #require(clientCapabilities["textDocument"] as? [String: Any]) - let formattingCapability = try #require(textDocumentCapabilities["formatting"] as? [String: Any]) - #expect(formattingCapability["dynamicRegistration"] as? Bool == true) - process.emitJSON([ - "jsonrpc": "2.0", - "id": initializeID, - "result": ["capabilities": [ - "definitionProvider": true, - "renameProvider": true, - "codeActionProvider": ["resolveProvider": true] - ]] - ]) - await Self.drainMainActorTasks() - let javaFeatures = manager.features(for: source) - #expect(javaFeatures.contains([.definition, .rename, .codeActions, .codeActionResolve])) - #expect(!javaFeatures.contains(.formatting)) - - process.emitJSON([ - "jsonrpc": "2.0", - "id": 501, - "method": "client/registerCapability", - "params": ["registrations": [[ - "id": "java-formatting", - "method": "textDocument/formatting", - "registerOptions": [:] - ]]] - ]) - await Self.drainMainActorTasks() - #expect(manager.features(for: source).contains(.formatting)) - #expect(Self.framedJSON(process.sentData.last)?["id"] as? Int == 501) - - process.emitJSON([ - "jsonrpc": "2.0", - "id": 502, - "method": "client/unregisterCapability", - "params": ["unregistrations": [[ - "id": "java-formatting", - "method": "textDocument/formatting" - ]]] - ]) - await Self.drainMainActorTasks() - #expect(!manager.features(for: source).contains(.formatting)) - #expect(Self.framedJSON(process.sentData.last)?["id"] as? Int == 502) - - let countBeforeRejectedFormat = process.sentData.count - #expect(throws: LanguageToolingSessionError.self) { - try manager.format(fileURL: source, text: "class Main {}\n", rootURL: root) { _ in } - } - #expect(process.sentData.count == countBeforeRejectedFormat) + let candidates = [ + JavaImplementationMarker(line: 0, utf16Column: 10, isType: true), + JavaImplementationMarker(line: 1, utf16Column: 6, isType: false) + ] - let text = "class Main { void oldName() {} }\n" - try manager.synchronizeLanguageServer(for: source, text: text, rootURL: root) - #expect(Self.framedJSON(process.sentData.last)?["method"] as? String == "textDocument/didOpen") + service.invalidate(document) + let markers = await service.markers(for: document, candidates: candidates) - var renameResult: Result? - try manager.rename( - fileURL: source, - text: text, - position: LanguageServerPosition(line: 0, utf16Column: 20), - newName: "newName", - rootURL: root - ) { renameResult = $0 } - let renameRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(renameRequest["method"] as? String == "textDocument/rename") - let renameID = try #require(renameRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", - "id": renameID, - "result": ["changes": [source.absoluteString: [[ - "range": [ - "start": ["line": 0, "character": 18], - "end": ["line": 0, "character": 25] - ], - "newText": "newName" - ]]]] - ]) - await Self.drainMainActorTasks() - #expect(try renameResult?.get().changes[source.standardizedFileURL]?.first?.newText == "newName") - - let unresolvedAction = LanguageServerCodeAction( - title: "Add import", - kind: "quickfix", - isPreferred: true, - edit: nil, - command: nil, - data: .object(["proposal": .integer(3)]) - ) - var resolvedActionResult: Result? - try manager.resolveCodeAction( - unresolvedAction, - fileURL: source, - text: text, - rootURL: root - ) { resolvedActionResult = $0 } - let resolveRequest = try #require(Self.framedJSON(process.sentData.last)) - #expect(resolveRequest["method"] as? String == "codeAction/resolve") - let resolveID = try #require(resolveRequest["id"] as? Int) - process.emitJSON([ - "jsonrpc": "2.0", "id": resolveID, - "result": [ - "title": "Add import", - "kind": "quickfix", - "edit": ["changes": [source.absoluteString: []]], - "data": ["proposal": 3] - ] - ]) - await Self.drainMainActorTasks() - #expect(try resolvedActionResult?.get().edit != nil) - - manager.stopAll() - #expect(!process.isRunning) - #expect(manager.activeLanguageServerIDs.isEmpty) + #expect(markers.isEmpty) } @Test @@ -3338,10 +2682,6 @@ private final class RecordingDebugAdapterTransport: DebugAdapterTransport, Debug } } -private struct EmptyArchiveEntryReader: ArchiveEntryReader { - func read(entry: String, from archive: URL) -> String? { nil } -} - private final class RecordingProcessFactory: @unchecked Sendable { private(set) var processes: [RecordingStreamingProcess] = [] @@ -3414,7 +2754,6 @@ private struct RunTestRuntimeLocator: RuntimeLocator { ) } func systemJDBExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdk/bin/jdb") } - func javaLanguageServerExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdtls") } } private struct MissingJavaRuntimeLocator: RuntimeLocator { @@ -3429,7 +2768,6 @@ private struct MissingJavaRuntimeLocator: RuntimeLocator { func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } func systemJDBExecutable() -> URL? { nil } - func javaLanguageServerExecutable() -> URL? { nil } } private struct XcrunOnlyRuntimeLocator: RuntimeLocator { @@ -3442,46 +2780,6 @@ private struct XcrunOnlyRuntimeLocator: RuntimeLocator { func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } func systemJDBExecutable() -> URL? { nil } - func javaLanguageServerExecutable() -> URL? { nil } -} - -@MainActor -private final class TestLanguageServerSession: LanguageServerSession { - private(set) var isRunning = false - private(set) var startCount = 0 - private(set) var stopCount = 0 - - func start(rootURL: URL) throws { - startCount += 1 - isRunning = true - } - - func stop() { - stopCount += 1 - isRunning = false - } -} - -@MainActor -private final class TestLanguageProviderRuntime: LanguageProviderRuntime { - let descriptor: LanguageProviderDescriptor - private(set) var languageServers: [TestLanguageServerSession] = [] - private(set) var makeCount = 0 - - var languageServer: TestLanguageServerSession { languageServers[0] } - - init(descriptor: LanguageProviderDescriptor) { - self.descriptor = descriptor - } - - func makeLanguageServerSession() -> (any LanguageServerSession)? { - makeCount += 1 - let session = TestLanguageServerSession() - languageServers.append(session) - return session - } - - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil } } @MainActor @@ -3531,7 +2829,7 @@ private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime { self.descriptor = descriptor supportsDebugAdapterSession = supportsDebugAdapter } - func makeLanguageServerSession() -> (any LanguageServerSession)? { nil } + func makeDebugAdapterSession() -> (any DebugAdapterSession)? { let session = TestDebugAdapterSession() debugAdapters.append(session) diff --git a/rust/lithe-core/include/lithe_core.h b/rust/lithe-core/include/lithe_core.h index 9005aa77..1444ee96 100644 --- a/rust/lithe-core/include/lithe_core.h +++ b/rust/lithe-core/include/lithe_core.h @@ -9,6 +9,7 @@ extern "C" { const char *lithe_core_version(void); char *lithe_core_execute_json(const char *request); +char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); void lithe_core_free_string(char *value); diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json new file mode 100644 index 00000000..fcb05692 --- /dev/null +++ b/rust/lithe-core/resources/lsp/language-providers.json @@ -0,0 +1,400 @@ +{ + "version": 1, + "providers": [ + { + "id": "java", + "displayName": "Java", + "fileExtensions": ["java"], + "capabilities": ["run", "languageServer", "formatting", "testing"], + "activationPolicy": "onDemand", + "languageId": "java" + }, + { + "id": "go", + "displayName": "Go", + "fileExtensions": ["go"], + "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], + "activationPolicy": "onDemand", + "languageId": "go" + }, + { + "id": "python", + "displayName": "Python", + "fileExtensions": ["py", "pyw"], + "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], + "activationPolicy": "onDemand", + "languageId": "python" + }, + { + "id": "node", + "displayName": "Node.js", + "fileExtensions": ["js", "jsx", "ts", "tsx", "mjs", "cjs"], + "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], + "activationPolicy": "onDemand", + "languageId": "javascript", + "languageIdsByExtension": { + "jsx": "javascriptreact", + "ts": "typescript", + "tsx": "typescriptreact" + } + }, + { + "id": "rust", + "displayName": "Rust", + "fileExtensions": ["rs"], + "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], + "activationPolicy": "onDemand", + "languageId": "rust" + }, + { + "id": "clangd", + "displayName": "C/C++/Objective-C", + "fileExtensions": ["c", "h", "hh", "hpp", "hxx", "cpp", "cc", "cxx", "m", "mm"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "cpp", + "languageIdsByExtension": { + "c": "c", + "h": "c", + "m": "objective-c", + "mm": "objective-cpp" + } + }, + { + "id": "csharp", + "displayName": "C#", + "fileExtensions": ["cs", "csx"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "csharp" + }, + { + "id": "fsharp", + "displayName": "F#", + "fileExtensions": ["fs", "fsi", "fsx"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "fsharp" + }, + { + "id": "swift", + "displayName": "Swift", + "fileExtensions": ["swift"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "swift" + }, + { + "id": "kotlin", + "displayName": "Kotlin", + "fileExtensions": ["kt", "kts"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "kotlin" + }, + { + "id": "scala", + "displayName": "Scala", + "fileExtensions": ["scala", "sc"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "scala" + }, + { + "id": "groovy", + "displayName": "Groovy", + "fileExtensions": ["groovy"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "groovy" + }, + { + "id": "ruby", + "displayName": "Ruby", + "fileExtensions": ["rb", "rake", "gemspec"], + "fileNames": ["Rakefile", "Gemfile"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "ruby" + }, + { + "id": "php", + "displayName": "PHP", + "fileExtensions": ["php", "phtml"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "php" + }, + { + "id": "dart", + "displayName": "Dart", + "fileExtensions": ["dart"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "dart" + }, + { + "id": "lua", + "displayName": "Lua", + "fileExtensions": ["lua"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "lua" + }, + { + "id": "shell", + "displayName": "Shell", + "fileExtensions": ["sh", "bash", "zsh", "fish", "ksh"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "shellscript" + }, + { + "id": "powershell", + "displayName": "PowerShell", + "fileExtensions": ["ps1", "psm1", "psd1"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "powershell" + }, + { + "id": "html", + "displayName": "HTML", + "fileExtensions": ["html", "htm", "xhtml"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "html" + }, + { + "id": "css", + "displayName": "CSS", + "fileExtensions": ["css", "scss", "sass", "less"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "css", + "languageIdsByExtension": { + "less": "less", + "sass": "sass", + "scss": "scss" + } + }, + { + "id": "vue", + "displayName": "Vue", + "fileExtensions": ["vue"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "vue" + }, + { + "id": "svelte", + "displayName": "Svelte", + "fileExtensions": ["svelte"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "svelte" + }, + { + "id": "astro", + "displayName": "Astro", + "fileExtensions": ["astro"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "astro" + }, + { + "id": "json", + "displayName": "JSON", + "fileExtensions": ["json", "jsonc", "json5"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "json", + "languageIdsByExtension": { + "jsonc": "jsonc" + } + }, + { + "id": "yaml", + "displayName": "YAML", + "fileExtensions": ["yml", "yaml"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "yaml" + }, + { + "id": "xml", + "displayName": "XML", + "fileExtensions": ["xml", "xsd", "wsdl", "pom"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "xml" + }, + { + "id": "markdown", + "displayName": "Markdown", + "fileExtensions": ["md", "markdown", "mdx"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "markdown" + }, + { + "id": "sql", + "displayName": "SQL", + "fileExtensions": ["sql"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "sql" + }, + { + "id": "terraform", + "displayName": "Terraform", + "fileExtensions": ["tf", "tfvars"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "terraform" + }, + { + "id": "dockerfile", + "displayName": "Dockerfile", + "fileExtensions": ["dockerfile"], + "fileNames": ["Dockerfile"], + "fileNamePrefixes": ["Dockerfile."], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "dockerfile", + "languageIdsByFileName": { + "Dockerfile": "dockerfile" + } + }, + { + "id": "cmake", + "displayName": "CMake", + "fileExtensions": ["cmake"], + "fileNames": ["CMakeLists.txt"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "cmake" + }, + { + "id": "make", + "displayName": "Make", + "fileExtensions": ["mk"], + "fileNames": ["Makefile", "GNUmakefile"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "makefile" + }, + { + "id": "toml", + "displayName": "TOML", + "fileExtensions": ["toml"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "toml" + }, + { + "id": "graphql", + "displayName": "GraphQL", + "fileExtensions": ["graphql", "gql"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "graphql" + }, + { + "id": "protobuf", + "displayName": "Protocol Buffers", + "fileExtensions": ["proto"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "proto" + }, + { + "id": "prisma", + "displayName": "Prisma", + "fileExtensions": ["prisma"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "prisma" + }, + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": ["ex", "exs"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "elixir" + }, + { + "id": "erlang", + "displayName": "Erlang", + "fileExtensions": ["erl", "hrl"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "erlang" + }, + { + "id": "haskell", + "displayName": "Haskell", + "fileExtensions": ["hs", "lhs"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "haskell" + }, + { + "id": "ocaml", + "displayName": "OCaml", + "fileExtensions": ["ml", "mli"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "ocaml" + }, + { + "id": "clojure", + "displayName": "Clojure", + "fileExtensions": ["clj", "cljs", "cljc", "edn"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "clojure" + }, + { + "id": "julia", + "displayName": "Julia", + "fileExtensions": ["jl"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "julia" + }, + { + "id": "r", + "displayName": "R", + "fileExtensions": ["r"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "r" + }, + { + "id": "perl", + "displayName": "Perl", + "fileExtensions": ["pl", "pm", "t"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "perl" + }, + { + "id": "zig", + "displayName": "Zig", + "fileExtensions": ["zig"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "zig" + }, + { + "id": "solidity", + "displayName": "Solidity", + "fileExtensions": ["sol"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "solidity" + } + ] +} diff --git a/rust/lithe-core/src/ffi.rs b/rust/lithe-core/src/ffi.rs index 04662dc2..62a0a979 100644 --- a/rust/lithe-core/src/ffi.rs +++ b/rust/lithe-core/src/ffi.rs @@ -1,5 +1,6 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::path::PathBuf; #[no_mangle] pub extern "C" fn lithe_core_version() -> *const c_char { @@ -18,6 +19,23 @@ pub unsafe extern "C" fn lithe_core_execute_json(request: *const c_char) -> *mut response_pointer(&crate::execute_json(&request)) } +#[no_mangle] +pub unsafe extern "C" fn lithe_core_lsp_provider_catalog_json( + workspace_root: *const c_char, +) -> *mut c_char { + let root = if workspace_root.is_null() { + None + } else { + let value = CStr::from_ptr(workspace_root).to_string_lossy(); + if value.trim().is_empty() { + None + } else { + Some(PathBuf::from(value.as_ref())) + } + }; + response_pointer(&crate::lsp::provider_catalog_json(root.as_deref())) +} + /// Requests cooperative cancellation of an in-flight operation. The call is /// thread-safe and returns 1 when an active operation was found. #[no_mangle] diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index c83af0d5..082c016d 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -7,6 +7,7 @@ mod ffi; mod git; mod history; mod java; +mod lsp; mod markdown; mod maven; mod model; @@ -2825,7 +2826,10 @@ mod tests { assert_eq!(plan["ok"], true, "{plan}"); assert_eq!(plan["data"]["executable"]["toolchain"], "project-go"); assert!(plan["data"]["executable"]["command"].is_null()); - assert_eq!(plan["data"]["arguments"], serde_json::json!(["run", "./cmd/api"])); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!(["run", "./cmd/api"]) + ); assert_eq!(plan["data"]["env"]["APP_ENV"], "dev"); assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); @@ -2852,10 +2856,13 @@ mod tests { let configurations = generated["data"]["generated"]["configurations"] .as_array() .unwrap(); - assert!(configurations.iter().any(|value| value["provider"] == "go.main")); - assert!(!configurations.iter().any(|value| value["id"] == "current-file")); - assert!(generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - .is_null()); + assert!(configurations + .iter() + .any(|value| value["provider"] == "go.main")); + assert!(!configurations + .iter() + .any(|value| value["id"] == "current-file")); + assert!(generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"].is_null()); fs::remove_dir_all(root).unwrap(); } @@ -2903,7 +2910,10 @@ mod tests { ("project-cargo", "rust", "1.82"), ] { assert_eq!(requirements[id]["type"], kind, "{requirements}"); - assert_eq!(requirements[id]["minimumVersion"], version, "{requirements}"); + assert_eq!( + requirements[id]["minimumVersion"], version, + "{requirements}" + ); } assert!(requirements["project-jdk"].is_null(), "{requirements}"); @@ -2976,11 +2986,16 @@ mod tests { .unwrap(); assert_eq!(updated["ok"], true, "{updated}"); let document: Value = serde_json::from_str( - updated["data"]["document"].as_str().expect("document string"), + updated["data"]["document"] + .as_str() + .expect("document string"), ) .unwrap(); let patch = &document["configurations"][0]; - assert_eq!(patch["args"], serde_json::json!(["app.py", "--port", "9000"])); + assert_eq!( + patch["args"], + serde_json::json!(["app.py", "--port", "9000"]) + ); assert_eq!(patch["env"]["APP_ENV"], "test"); assert!(patch["extensions"]["maven"].is_null()); @@ -2999,7 +3014,10 @@ mod tests { )) .unwrap(); assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["arguments"], serde_json::json!(["app.py", "--port", "9000"])); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!(["app.py", "--port", "9000"]) + ); assert_eq!(plan["data"]["env"]["APP_ENV"], "test"); fs::remove_dir_all(root).unwrap(); diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs new file mode 100644 index 00000000..a9c5fa90 --- /dev/null +++ b/rust/lithe-core/src/lsp.rs @@ -0,0 +1,392 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const BUILTIN_LANGUAGE_PROVIDERS: &str = include_str!("../resources/lsp/language-providers.json"); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderCatalog { + pub version: u32, + pub providers: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderConfigDiagnostic { + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderDescriptor { + pub id: String, + pub display_name: String, + pub file_extensions: Vec, + pub file_names: Vec, + pub file_name_prefixes: Vec, + pub capabilities: Vec, + pub activation_policy: LspActivationPolicy, + pub language_id: Option, + pub language_ids_by_extension: BTreeMap, + pub language_ids_by_file_name: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspProviderCapability { + Run, + LanguageServer, + DebugAdapter, + Formatting, + Testing, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspActivationPolicy { + OnDemand, + Always, +} + +impl Default for LspActivationPolicy { + fn default() -> Self { + Self::OnDemand + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LspProviderConfigDocument { + #[serde(default = "default_config_version")] + version: u32, + #[serde(default)] + providers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LspProviderPatch { + id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + file_extensions: Option>, + #[serde(default)] + file_names: Option>, + #[serde(default)] + file_name_prefixes: Option>, + #[serde(default)] + capabilities: Option>, + #[serde(default)] + activation_policy: Option, + #[serde(default)] + language_id: Option, + #[serde(default)] + language_ids_by_extension: Option>, + #[serde(default)] + language_ids_by_file_name: Option>, + #[serde(default)] + disabled: bool, +} + +pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { + let catalog = provider_catalog(workspace_root); + serde_json::to_string(&catalog) + .unwrap_or_else(|_| "{\"version\":1,\"providers\":[]}".to_string()) +} + +pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { + let mut diagnostics = Vec::new(); + let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { + Ok(document) => document, + Err(message) => { + diagnostics.push(LspProviderConfigDiagnostic { + path: "builtin:lsp".to_string(), + message, + }); + LspProviderConfigDocument { + version: 1, + providers: Vec::new(), + } + } + }; + + if let Some(root) = workspace_root { + let path = project_config_path(root); + if path.is_file() { + match std::fs::read_to_string(&path) { + Ok(raw) => match parse_document(&raw, &path.display().to_string()) { + Ok(project_document) => { + document = merge_documents(document, project_document); + } + Err(message) => diagnostics.push(LspProviderConfigDiagnostic { + path: path.display().to_string(), + message, + }), + }, + Err(error) => diagnostics.push(LspProviderConfigDiagnostic { + path: path.display().to_string(), + message: error.to_string(), + }), + } + } + } + + let mut providers = Vec::new(); + for patch in document.providers { + if patch.disabled { + continue; + } + providers.push(LspProviderDescriptor::from_patch(patch)); + } + LspProviderCatalog { + version: document.version, + providers, + diagnostics, + } +} + +fn parse_document(raw: &str, source: &str) -> Result { + serde_json::from_str(raw).map_err(|error| format!("{source}: {error}")) +} + +fn merge_documents( + mut base: LspProviderConfigDocument, + project: LspProviderConfigDocument, +) -> LspProviderConfigDocument { + base.version = project.version.max(base.version); + for patch in project.providers { + if let Some(existing) = base + .providers + .iter_mut() + .find(|provider| provider.id == patch.id) + { + existing.apply(patch); + } else { + base.providers.push(patch); + } + } + base +} + +fn project_config_path(root: &Path) -> PathBuf { + root.join(".lithe") + .join("lsp") + .join("language-providers.json") +} + +fn default_config_version() -> u32 { + 1 +} + +impl LspProviderPatch { + fn apply(&mut self, patch: LspProviderPatch) { + if patch.display_name.is_some() { + self.display_name = patch.display_name; + } + if patch.file_extensions.is_some() { + self.file_extensions = patch.file_extensions; + } + if patch.file_names.is_some() { + self.file_names = patch.file_names; + } + if patch.file_name_prefixes.is_some() { + self.file_name_prefixes = patch.file_name_prefixes; + } + if patch.capabilities.is_some() { + self.capabilities = patch.capabilities; + } + if patch.activation_policy.is_some() { + self.activation_policy = patch.activation_policy; + } + if patch.language_id.is_some() { + self.language_id = patch.language_id; + } + if patch.language_ids_by_extension.is_some() { + self.language_ids_by_extension = patch.language_ids_by_extension; + } + if patch.language_ids_by_file_name.is_some() { + self.language_ids_by_file_name = patch.language_ids_by_file_name; + } + self.disabled = patch.disabled; + } +} + +impl LspProviderDescriptor { + fn from_patch(patch: LspProviderPatch) -> Self { + let id = normalized_id(&patch.id); + let display_name = patch + .display_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| id.clone()); + let capabilities = patch.capabilities.unwrap_or_else(|| { + vec![ + LspProviderCapability::LanguageServer, + LspProviderCapability::Formatting, + ] + }); + Self { + id: id.clone(), + display_name, + file_extensions: normalized_values(patch.file_extensions.unwrap_or_default(), true), + file_names: normalized_values(patch.file_names.unwrap_or_default(), false), + file_name_prefixes: normalized_values( + patch.file_name_prefixes.unwrap_or_default(), + false, + ), + capabilities, + activation_policy: patch.activation_policy.unwrap_or_default(), + language_id: patch.language_id.filter(|value| !value.trim().is_empty()), + language_ids_by_extension: normalized_map( + patch.language_ids_by_extension.unwrap_or_default(), + true, + ), + language_ids_by_file_name: normalized_map( + patch.language_ids_by_file_name.unwrap_or_default(), + false, + ), + } + } +} + +fn normalized_id(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn normalized_values(values: Vec, trim_dot: bool) -> Vec { + let mut result = Vec::new(); + for value in values { + let normalized = normalized_key(&value, trim_dot); + if !normalized.is_empty() && !result.contains(&normalized) { + result.push(normalized); + } + } + result +} + +fn normalized_map(values: BTreeMap, trim_dot: bool) -> BTreeMap { + values + .into_iter() + .filter_map(|(key, value)| { + let key = normalized_key(&key, trim_dot); + if key.is_empty() || value.trim().is_empty() { + None + } else { + Some((key, value)) + } + }) + .collect() +} + +fn normalized_key(value: &str, trim_dot: bool) -> String { + let mut value = value.trim().to_ascii_lowercase(); + if trim_dot { + value = value.trim_start_matches('.').to_string(); + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!("lithe-lsp-{label}-{}-{nonce}", std::process::id())) + } + + #[test] + fn builtin_catalog_describes_market_lsp_providers() { + let catalog = provider_catalog(None); + let ids: Vec<_> = catalog + .providers + .iter() + .map(|provider| provider.id.as_str()) + .collect(); + assert!(ids.starts_with(&["java", "go", "python", "node", "rust"])); + assert!(ids.contains(&"swift")); + assert!(ids.contains(&"clangd")); + assert!(ids.contains(&"dockerfile")); + assert!(ids.contains(&"graphql")); + let clangd = catalog + .providers + .iter() + .find(|provider| provider.id == "clangd") + .expect("clangd provider should exist"); + assert_eq!( + clangd.language_ids_by_extension.get("m"), + Some(&"objective-c".to_string()) + ); + } + + #[test] + fn project_config_extends_and_overrides_builtin_catalog() { + let root = temporary_root("project-config"); + fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); + fs::write( + root.join(".lithe/lsp/language-providers.json"), + r#"{ + "version": 1, + "providers": [ + { + "id": "roc", + "displayName": "Roc", + "fileExtensions": ["roc"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "roc" + }, + { + "id": "swift", + "fileExtensions": ["swift", "swiftinterface"] + }, + { + "id": "perl", + "disabled": true + } + ] + }"#, + ) + .unwrap(); + + let catalog = provider_catalog(Some(&root)); + assert!(catalog + .providers + .iter() + .any(|provider| provider.id == "roc")); + let swift = catalog + .providers + .iter() + .find(|provider| provider.id == "swift") + .expect("swift provider should still exist"); + assert!(swift + .file_extensions + .contains(&"swiftinterface".to_string())); + assert!(!catalog + .providers + .iter() + .any(|provider| provider.id == "perl")); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ffi_json_is_a_standalone_catalog_document() { + let raw = provider_catalog_json(None); + let value: Value = serde_json::from_str(&raw).expect("catalog should be JSON"); + assert_eq!(value["version"], 1); + assert!(value["providers"].as_array().unwrap().len() > 10); + assert!(value.get("ok").is_none()); + assert!(value.get("command").is_none()); + } +} diff --git a/rust/lithe-core/src/run_configuration.rs b/rust/lithe-core/src/run_configuration.rs index cd7a69a6..4254f512 100644 --- a/rust/lithe-core/src/run_configuration.rs +++ b/rust/lithe-core/src/run_configuration.rs @@ -686,9 +686,15 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result })?; let provider = resolved["configurations"] .as_array() - .and_then(|items| items.iter().find(|value| value["id"] == request.configuration_id)) + .and_then(|items| { + items + .iter() + .find(|value| value["id"] == request.configuration_id) + }) .and_then(|value| value["provider"].as_str()) - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found"))?; + .ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found") + })?; let uses_maven_capability = matches!( provider, "java.current-file" | "java.main" | "spring-boot.maven" | "maven.module" @@ -1836,10 +1842,9 @@ fn declared_go_version(root: &Path) -> Option { } fn declared_python_version(root: &Path) -> Option { - let expression = regex::Regex::new( - r#"(?m)^\s*(?:requires-python|python)\s*=\s*[\"']([^\"']+)[\"']"#, - ) - .ok()?; + let expression = + regex::Regex::new(r#"(?m)^\s*(?:requires-python|python)\s*=\s*[\"']([^\"']+)[\"']"#) + .ok()?; highest_version( project_manifest_paths(root, &["pyproject.toml"]) .into_iter() diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index bf305009..5935f8b5 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -3,6 +3,9 @@ import Foundation @_silgen_name("lithe_bridge_execute_json") private func executeJSON(_ request: UnsafePointer) -> UnsafeMutablePointer? +@_silgen_name("lithe_bridge_lsp_provider_catalog_json") +private func lspProviderCatalogJSON(_ workspaceRoot: UnsafePointer?) -> UnsafeMutablePointer? + @_silgen_name("lithe_bridge_free_string") private func freeJSON(_ value: UnsafeMutablePointer) @@ -25,3 +28,20 @@ guard let data = response.data(using: .utf8), } print("Rust Core bridge response passed: \(response)") + +guard let catalogPointer = lspProviderCatalogJSON(nil) else { + fputs("Rust Core LSP provider bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(catalogPointer) } + +let catalogResponse = String(cString: catalogPointer) +guard let catalogData = catalogResponse.data(using: .utf8), + let catalog = try? JSONSerialization.jsonObject(with: catalogData) as? [String: Any], + let providers = catalog["providers"] as? [[String: Any]], + providers.contains(where: { $0["id"] as? String == "swift" }) else { + fputs("Unexpected Rust Core LSP provider catalog: \(catalogResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP provider catalog passed: \(providers.count) providers") diff --git a/scripts/verify-rust-core.sh b/scripts/verify-rust-core.sh index b6a7ac9f..ec5a9292 100755 --- a/scripts/verify-rust-core.sh +++ b/scripts/verify-rust-core.sh @@ -38,5 +38,9 @@ if ! nm -gU "$BINARY" | grep -F "_lithe_core_execute_json" > /dev/null; then print -u2 -- "Rust Core symbols are missing from the macOS binary" exit 1 fi +if ! nm -gU "$BINARY" | grep -F "_lithe_core_lsp_provider_catalog_json" > /dev/null; then + print -u2 -- "Rust Core LSP provider catalog symbol is missing from the macOS binary" + exit 1 +fi print "Rust Core verification passed: Rust tests, Swift bridge build, and linked symbols" diff --git a/scripts/verify-service-boundaries.sh b/scripts/verify-service-boundaries.sh index 182738c4..42a8246b 100755 --- a/scripts/verify-service-boundaries.sh +++ b/scripts/verify-service-boundaries.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" core_pattern='import (SwiftUI|AppKit|CoreServices)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle' service_pattern='import (SwiftUI|AppKit)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle|String\(contentsOf:|Data\(contentsOf:|write\(to:.*encoding:|\bMac[A-Z][A-Za-z]+\b|/opt/homebrew|/usr/local|/usr/bin' -ui_service_pattern='\b(MavenService|JavaRunService|JavaDebugService|ProjectRuntimeService|JavaLanguageService|JavaImplementationMarkerService|GitService|WorkspaceSearchIndex)\b' +ui_service_pattern='\b(MavenService|JavaRunService|JavaDebugService|ProjectRuntimeService|JavaImplementationMarkerService|GitService|WorkspaceSearchIndex)\b' composition_pattern='\bMac[A-Z][A-Za-z]+\b' application_ui_pattern='import AppKit|\b(NSOpenPanel|NSWorkspace|NSPasteboard|NSEvent)\b' appmodel_business_pattern='Task\.detached|LocalHistoryService|WorkspaceTextFilePolicy|DirectoryChangeSource|fileOperations\.(fileExists|isDirectory|createFile|createDirectory|copyItem|moveItem|removeItem|trashItem|writeText)|mavenFeature\.loadProject|runFeature\.loadProject|configuration\.kind|debugFeature\.(startMaven|toggleBreakpoint|attachRemote)' From 39b801103c0e79d888c1e42bfb0d0dba4e28ea80 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 10 Aug 2026 23:57:10 +0800 Subject: [PATCH 05/38] Move LSP text edits into Rust core --- Sources/Lithe/Core/RustCoreBridge.swift | 63 ++++ .../LanguageServerTextEditApplicator.swift | 19 ++ rust/lithe-core/src/command.rs | 4 + rust/lithe-core/src/lsp.rs | 315 ++++++++++++++++++ rust/lithe-core/src/runtime.rs | 30 ++ scripts/RustCoreBridgeVerification.swift | 38 +++ shared/contracts/rust-core-api.md | 13 + 7 files changed, 482 insertions(+) diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 9d205194..0adb953f 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -384,6 +384,10 @@ struct RustCoreBridge: Sendable { let port: Int? } + struct TextPayload: Decodable, Sendable { + let text: String + } + struct JavaStructurePayload: Decodable, Sendable { struct FoldRegion: Decodable, Sendable { let kind: String @@ -792,6 +796,30 @@ struct RustCoreBridge: Sendable { let source: String } + private struct LspTextEditsRequest: Encodable { + struct TextEdit: Encodable { + struct Range: Encodable { + struct Position: Encodable { + let line: Int + let utf16Column: Int + } + + let start: Position + let end: Position + } + + let range: Range + let newText: String + } + + let text: String + let edits: [TextEdit] + } + + private struct LspPlainSnippetRequest: Encodable { + let value: String + } + private struct MavenDiagnosticsRequest: Encodable { let root: String let output: String @@ -1639,6 +1667,41 @@ struct RustCoreBridge: Sendable { ) } + func applyLanguageServerTextEdits( + _ edits: [LanguageServerTextEdit], + to text: String + ) -> Result { + executeResult( + command: "lsp.applyTextEdits", + payload: LspTextEditsRequest( + text: text, + edits: edits.map { edit in + LspTextEditsRequest.TextEdit( + range: LspTextEditsRequest.TextEdit.Range( + start: LspTextEditsRequest.TextEdit.Range.Position( + line: edit.range.start.line, + utf16Column: edit.range.start.utf16Column + ), + end: LspTextEditsRequest.TextEdit.Range.Position( + line: edit.range.end.line, + utf16Column: edit.range.end.utf16Column + ) + ), + newText: edit.newText + ) + } + ) + ) + } + + func plainLanguageServerSnippet(_ value: String) -> String? { + let response: TextPayload? = execute( + command: "lsp.plainSnippet", + payload: LspPlainSnippetRequest(value: value) + ) + return response?.text + } + private func execute( command: String, payload: Payload diff --git a/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift b/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift index 1b734653..2de10d61 100644 --- a/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift +++ b/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift @@ -14,6 +14,22 @@ enum LanguageServerTextEditApplicator { } static func apply(_ edits: [LanguageServerTextEdit], to text: String) throws -> String { + let core = RustCoreBridge() + if core.isAvailable { + switch core.applyLanguageServerTextEdits(edits, to: text) { + case .success(let payload): + return payload.text + case .failure(let error): + switch error.details { + case "overlappingEdits": throw Error.overlappingEdits + default: throw Error.invalidRange + } + } + } + return try applyFallback(edits, to: text) + } + + private static func applyFallback(_ edits: [LanguageServerTextEdit], to text: String) throws -> String { let source = text as NSString var replacements: [(NSRange, String)] = [] for edit in edits { @@ -59,6 +75,9 @@ enum LanguageServerTextEditApplicator { enum LanguageServerSnippet { static func plainText(_ value: String) -> String { + if let text = RustCoreBridge().plainLanguageServerSnippet(value) { + return text + } var result = value if let placeholders = try? NSRegularExpression(pattern: #"\$\{\d+:([^}]*)\}"#) { result = placeholders.stringByReplacingMatches( diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index a6ee116c..e6fcd0bc 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -31,6 +31,8 @@ pub enum CoreCommand { MavenScan, MavenDiagnostics, MarkdownRender, + LspApplyTextEdits, + LspPlainSnippet, JavaRunConfigurations, RunConfigInspect, RunConfigGenerate, @@ -78,6 +80,8 @@ impl CoreCommand { "maven.scan" => Some(Self::MavenScan), "maven.diagnostics" => Some(Self::MavenDiagnostics), "markdown.render" => Some(Self::MarkdownRender), + "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), + "lsp.plainSnippet" => Some(Self::LspPlainSnippet), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), "runConfig.generate" => Some(Self::RunConfigGenerate), diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index a9c5fa90..f4852932 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use crate::error::{CoreError, ErrorCode}; + const BUILTIN_LANGUAGE_PROVIDERS: &str = include_str!("../resources/lsp/language-providers.json"); #[derive(Debug, Clone, Serialize)] @@ -93,12 +95,91 @@ struct LspProviderPatch { disabled: bool, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyTextEditsRequest { + pub text: String, + #[serde(default)] + pub edits: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspTextEdit { + pub range: LspRange, + pub new_text: String, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRange { + pub start: LspPosition, + pub end: LspPosition, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspPosition { + pub line: i64, + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextResponse { + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlainSnippetRequest { + pub value: String, +} + pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { let catalog = provider_catalog(workspace_root); serde_json::to_string(&catalog) .unwrap_or_else(|_| "{\"version\":1,\"providers\":[]}".to_string()) } +pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result { + let mut replacements = Vec::new(); + for edit in request.edits { + let start = utf16_position_to_byte_offset(&request.text, edit.range.start)?; + let end = utf16_position_to_byte_offset(&request.text, edit.range.end)?; + if end < start { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange")); + } + replacements.push((start, end, edit.new_text)); + } + replacements.sort_by_key(|(start, _, _)| *start); + for pair in replacements.windows(2) { + if pair[0].1 > pair[1].0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned overlapping text edits.", + ) + .with_details("overlappingEdits")); + } + } + + let mut text = request.text; + for (start, end, replacement) in replacements.into_iter().rev() { + text.replace_range(start..end, &replacement); + } + Ok(TextResponse { text }) +} + +pub fn plain_snippet(request: PlainSnippetRequest) -> TextResponse { + TextResponse { + text: snippet_plain_text(&request.value), + } +} + pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { let mut diagnostics = Vec::new(); let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { @@ -290,6 +371,140 @@ fn normalized_key(value: &str, trim_dot: bool) -> String { value } +fn utf16_position_to_byte_offset(text: &str, position: LspPosition) -> Result { + if position.line < 0 || position.utf16_column < 0 { + return Err(invalid_range_error()); + } + let line = usize::try_from(position.line).map_err(|_| invalid_range_error())?; + let column = usize::try_from(position.utf16_column).map_err(|_| invalid_range_error())?; + let Some((start, contents_end)) = line_bounds(text, line) else { + return Err(invalid_range_error()); + }; + Ok(byte_offset_for_utf16_column( + text, + start, + contents_end, + column, + )) +} + +fn invalid_range_error() -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange") +} + +fn line_bounds(text: &str, target_line: usize) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + let mut line = 0; + let mut start = 0; + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'\n' { + if line == target_line { + let contents_end = if index > start && bytes[index - 1] == b'\r' { + index - 1 + } else { + index + }; + return Some((start, contents_end)); + } + line += 1; + start = index + 1; + } + } + if line == target_line { + Some((start, text.len())) + } else { + None + } +} + +fn byte_offset_for_utf16_column( + text: &str, + start: usize, + contents_end: usize, + column: usize, +) -> usize { + let mut units = 0; + for (relative, character) in text[start..contents_end].char_indices() { + let next_units = units + character.len_utf16(); + if next_units > column { + return start + relative; + } + units = next_units; + if units == column { + return start + relative + character.len_utf8(); + } + } + contents_end +} + +fn snippet_plain_text(value: &str) -> String { + let mut output = String::new(); + let mut chars = value.chars().peekable(); + while let Some(character) = chars.next() { + if character != '$' { + output.push(character); + continue; + } + match chars.peek().copied() { + Some('{') => { + chars.next(); + if !consume_digits(&mut chars) { + output.push_str("${"); + continue; + } + match chars.peek().copied() { + Some(':') => { + chars.next(); + output.push_str(&consume_until_placeholder_end(&mut chars)); + } + Some('}') => { + chars.next(); + } + _ => output.push('$'), + } + } + Some(next) if next.is_ascii_digit() => { + consume_digits(&mut chars); + } + _ => output.push('$'), + } + } + output +} + +fn consume_digits(chars: &mut std::iter::Peekable) -> bool +where + I: Iterator, +{ + let mut consumed = false; + while chars + .peek() + .is_some_and(|character| character.is_ascii_digit()) + { + chars.next(); + consumed = true; + } + consumed +} + +fn consume_until_placeholder_end(chars: &mut std::iter::Peekable) -> String +where + I: Iterator, +{ + let mut value = String::new(); + for character in chars.by_ref() { + if character == '}' { + break; + } + value.push(character); + } + value +} + #[cfg(test)] mod tests { use super::*; @@ -389,4 +604,104 @@ mod tests { assert!(value.get("ok").is_none()); assert!(value.get("command").is_none()); } + + #[test] + fn text_edits_use_lsp_utf16_positions() { + let response = apply_text_edits(ApplyTextEditsRequest { + text: "one 😀\ntwo three\n".to_string(), + edits: vec![ + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 4, + }, + end: LspPosition { + line: 0, + utf16_column: 6, + }, + }, + new_text: "rocket".to_string(), + }, + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 1, + utf16_column: 4, + }, + end: LspPosition { + line: 1, + utf16_column: 9, + }, + }, + new_text: "four".to_string(), + }, + ], + }) + .unwrap(); + + assert_eq!(response.text, "one rocket\ntwo four\n"); + } + + #[test] + fn text_edits_reject_invalid_and_overlapping_ranges() { + let invalid = apply_text_edits(ApplyTextEditsRequest { + text: "one line".to_string(), + edits: vec![LspTextEdit { + range: LspRange { + start: LspPosition { + line: 9, + utf16_column: 0, + }, + end: LspPosition { + line: 9, + utf16_column: 1, + }, + }, + new_text: "x".to_string(), + }], + }) + .unwrap_err(); + assert_eq!(invalid.details.as_deref(), Some("invalidRange")); + + let overlapping = apply_text_edits(ApplyTextEditsRequest { + text: "one line".to_string(), + edits: vec![ + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 4, + }, + }, + new_text: "a".to_string(), + }, + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 2, + }, + end: LspPosition { + line: 0, + utf16_column: 6, + }, + }, + new_text: "b".to_string(), + }, + ], + }) + .unwrap_err(); + assert_eq!(overlapping.details.as_deref(), Some("overlappingEdits")); + } + + #[test] + fn snippet_plain_text_removes_tab_stops_and_keeps_defaults() { + assert_eq!(snippet_plain_text("print(${1:value})$0"), "print(value)"); + assert_eq!(snippet_plain_text("${1:let} ${2:name} = $3"), "let name = "); + } } diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 44a08498..54c75576 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -264,6 +264,36 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspApplyTextEdits => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP text edit request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::apply_text_edits) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP text edit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspPlainSnippet => { + match serde_json::from_value::(parsed.payload).map_err( + |error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP snippet request") + .with_details(error.to_string()) + }, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::lsp::plain_snippet(request)) + .expect("LSP snippet response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::JavaRunConfigurations => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index 5935f8b5..fee4d098 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -45,3 +45,41 @@ guard let catalogData = catalogResponse.data(using: .utf8), } print("Rust Core LSP provider catalog passed: \(providers.count) providers") + +let editRequest = """ +{"id":"lsp-edit-test","command":"lsp.applyTextEdits","payload":{"text":"one 😀\\ntwo three\\n","edits":[{"range":{"start":{"line":0,"utf16Column":4},"end":{"line":0,"utf16Column":6}},"newText":"rocket"},{"range":{"start":{"line":1,"utf16Column":4},"end":{"line":1,"utf16Column":9}},"newText":"four"}]}} +""" +guard let editPointer = editRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP text edit bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(editPointer) } + +let editResponse = String(cString: editPointer) +guard let editData = editResponse.data(using: .utf8), + let editEnvelope = try? JSONSerialization.jsonObject(with: editData) as? [String: Any], + let editPayload = editEnvelope["data"] as? [String: Any], + editPayload["text"] as? String == "one rocket\ntwo four\n" else { + fputs("Unexpected Rust Core LSP text edit response: \(editResponse)\n", stderr) + exit(1) +} + +let snippetRequest = """ +{"id":"lsp-snippet-test","command":"lsp.plainSnippet","payload":{"value":"print(${1:value})$0"}} +""" +guard let snippetPointer = snippetRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP snippet bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(snippetPointer) } + +let snippetResponse = String(cString: snippetPointer) +guard let snippetData = snippetResponse.data(using: .utf8), + let snippetEnvelope = try? JSONSerialization.jsonObject(with: snippetData) as? [String: Any], + let snippetPayload = snippetEnvelope["data"] as? [String: Any], + snippetPayload["text"] as? String == "print(value)" else { + fputs("Unexpected Rust Core LSP snippet response: \(snippetResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP text edit and snippet bridge passed") diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2f3069e1..59557b88 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -6,6 +6,7 @@ Qt/C++. Both bindings call the same C ABI: ```c const char *lithe_core_version(void); char *lithe_core_execute_json(const char *request); +char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); void lithe_core_free_string(char *value); ``` @@ -69,6 +70,8 @@ stable error code and a user-facing message: | `history.relocate` | Move a file's history records after a rename | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | +| `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | +| `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | | `java.runConfigurations` | Scan Java sources for main classes and return Maven/Spring run configurations | | `java.codeVision` | Return Java declaration usage counts for editor code vision | | `java.className` | Resolve a Java source package and simple name into a runtime class name | @@ -170,6 +173,16 @@ where each file contains replacement matches and the complete `replacementText` to write. The command never writes files; callers can record history before using `file.write` for the selected files. +`lsp.applyTextEdits` accepts `{ "text": string, "edits": [] }`, where each edit +has an LSP range with zero-based `line` and UTF-16 `utf16Column` fields plus +`newText`. Ranges are validated and overlapping edits return +`invalid_request` with details `overlappingEdits`; invalid positions return +details `invalidRange`. Successful responses return `{ "text": string }`. + +`lsp.plainSnippet` accepts `{ "value": string }` and returns `{ "text": string }` +after removing LSP tab stops and replacing simple placeholder defaults such as +`${1:name}` with `name`. + The `history.*` commands accept an adapter-selected `storageRoot`; history metadata never stores an absolute workspace or storage path. `history.record` accepts `workspaceRoot`, a relative `path`, a `reason`, and optional UTF-8 From 853d1b8c3da0a5fe41d11649614ae9ae8c63a7a1 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 00:03:23 +0800 Subject: [PATCH 06/38] Add Rust builtin LSP fallbacks --- Sources/Lithe/Core/RustCoreBridge.swift | 163 ++++++ .../LanguageToolingSessionManager.swift | 62 ++- rust/lithe-core/src/command.rs | 6 + rust/lithe-core/src/lsp.rs | 477 ++++++++++++++++++ rust/lithe-core/src/runtime.rs | 45 ++ scripts/RustCoreBridgeVerification.swift | 40 ++ shared/contracts/rust-core-api.md | 13 + 7 files changed, 791 insertions(+), 15 deletions(-) diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 0adb953f..c7010505 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -388,6 +388,106 @@ struct RustCoreBridge: Sendable { let text: String } + struct LspPositionPayload: Decodable, Sendable { + let line: Int + let utf16Column: Int + + func makeModel() -> LanguageServerPosition { + LanguageServerPosition(line: line, utf16Column: utf16Column) + } + } + + struct LspRangePayload: Decodable, Sendable { + let start: LspPositionPayload + let end: LspPositionPayload + + func makeModel() -> LanguageServerRange { + LanguageServerRange(start: start.makeModel(), end: end.makeModel()) + } + } + + struct LspTextEditPayload: Decodable, Sendable { + let range: LspRangePayload + let newText: String + + func makeModel() -> LanguageServerTextEdit { + LanguageServerTextEdit(range: range.makeModel(), newText: newText) + } + } + + struct BuiltinCompletionPayload: Decodable, Sendable { + struct Item: Decodable, Sendable { + let label: String + let insertText: String + let kind: Int? + let detail: String? + let textEdit: LspTextEditPayload + + func makeModel() -> LanguageServerCompletionItem { + LanguageServerCompletionItem( + label: label, + detail: detail, + documentation: nil, + insertText: insertText, + sortText: nil, + filterText: nil, + kind: kind, + textEdit: textEdit.makeModel(), + additionalTextEdits: [], + data: nil + ) + } + } + + let items: [Item] + + func makeModels() -> [LanguageServerCompletionItem] { + items.map { $0.makeModel() } + } + } + + struct BuiltinHoverPayload: Decodable, Sendable { + struct Hover: Decodable, Sendable { + let contents: String + let isMarkdown: Bool + let range: LspRangePayload + + func makeModel() -> LanguageServerHover { + LanguageServerHover( + contents: contents, + isMarkdown: isMarkdown, + range: range.makeModel() + ) + } + } + + let hover: Hover? + } + + struct BuiltinNavigationPayload: Decodable, Sendable { + struct Location: Decodable, Sendable { + let filePath: String + let range: LspRangePayload + let isReadOnly: Bool + let displayPath: String? + + func makeModel() -> LanguageServerLocation { + LanguageServerLocation( + url: URL(fileURLWithPath: filePath), + range: range.makeModel(), + isReadOnly: isReadOnly, + displayPath: displayPath + ) + } + } + + let locations: [Location] + + func makeModels() -> [LanguageServerLocation] { + locations.map { $0.makeModel() } + } + } + struct JavaStructurePayload: Decodable, Sendable { struct FoldRegion: Decodable, Sendable { let kind: String @@ -820,6 +920,19 @@ struct RustCoreBridge: Sendable { let value: String } + private struct LspBuiltinRequest: Encodable { + let filePath: String + let text: String + let position: LspTextEditsRequest.TextEdit.Range.Position + } + + private struct LspBuiltinNavigationRequest: Encodable { + let filePath: String + let text: String + let position: LspTextEditsRequest.TextEdit.Range.Position + let method: String + } + private struct MavenDiagnosticsRequest: Encodable { let root: String let output: String @@ -1702,6 +1815,56 @@ struct RustCoreBridge: Sendable { return response?.text } + func builtinLanguageCompletions( + fileURL: URL, + text: String, + position: LanguageServerPosition + ) -> [LanguageServerCompletionItem]? { + let response: BuiltinCompletionPayload? = execute( + command: "lsp.builtinCompletions", + payload: LspBuiltinRequest( + filePath: fileURL.standardizedFileURL.path, + text: text, + position: .init(line: position.line, utf16Column: position.utf16Column) + ) + ) + return response?.makeModels() + } + + func builtinLanguageHover( + fileURL: URL, + text: String, + position: LanguageServerPosition + ) -> LanguageServerHover? { + let response: BuiltinHoverPayload? = execute( + command: "lsp.builtinHover", + payload: LspBuiltinRequest( + filePath: fileURL.standardizedFileURL.path, + text: text, + position: .init(line: position.line, utf16Column: position.utf16Column) + ) + ) + return response?.hover?.makeModel() + } + + func builtinLanguageNavigation( + method: String, + fileURL: URL, + text: String, + position: LanguageServerPosition + ) -> [LanguageServerLocation]? { + let response: BuiltinNavigationPayload? = execute( + command: "lsp.builtinNavigation", + payload: LspBuiltinNavigationRequest( + filePath: fileURL.standardizedFileURL.path, + text: text, + position: .init(line: position.line, utf16Column: position.utf16Column), + method: method + ) + ) + return response?.makeModels() + } + private func execute( command: String, payload: Payload diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 3b445f5e..590937e6 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -35,6 +35,7 @@ final class LanguageToolingSessionManager: ObservableObject { var onDebugEvent: ((String, DebugAdapterEvent) -> Void)? private var catalog: LanguageProviderCatalog + private let core: RustCoreBridge private var runtimesByID: [String: any LanguageProviderRuntime] private var debugAdapters: [String: any DebugAdapterSession] = [:] private var debugAdapterRoots: [String: URL] = [:] @@ -42,9 +43,11 @@ final class LanguageToolingSessionManager: ObservableObject { init( catalog: LanguageProviderCatalog = .standard, - runtimes: [any LanguageProviderRuntime] = [] + runtimes: [any LanguageProviderRuntime] = [], + core: RustCoreBridge = RustCoreBridge() ) { self.catalog = catalog + self.core = core runtimesByID = Dictionary(uniqueKeysWithValues: runtimes.map { ($0.descriptor.id, $0) }) } @@ -81,7 +84,9 @@ final class LanguageToolingSessionManager: ObservableObject { func features(for fileURL: URL) -> LanguageServerFeatureSet { guard let descriptor = catalog.provider(for: fileURL) else { return [] } - return languageServerFeatures[descriptor.id] ?? [] + if let features = languageServerFeatures[descriptor.id] { return features } + guard descriptor.capabilities.contains(.languageServer), core.isAvailable else { return [] } + return [.definition, .references, .implementation, .hover, .completion] } func synchronizeLanguageServer( @@ -114,34 +119,56 @@ final class LanguageToolingSessionManager: ObservableObject { } func navigate( - method _: String, + method: String, fileURL: URL, - text _: String, - position _: LanguageServerPosition, + text: String, + position: LanguageServerPosition, rootURL _: URL, - completion _: @escaping (Result<[LanguageServerLocation], Error>) -> Void + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { - throw unavailableLanguageServerError(for: fileURL) + guard supportsBuiltinLanguageServer(for: fileURL) else { + throw unavailableLanguageServerError(for: fileURL) + } + completion(.success(core.builtinLanguageNavigation( + method: method, + fileURL: fileURL, + text: text, + position: position + ) ?? [])) } func hover( fileURL: URL, - text _: String, - position _: LanguageServerPosition, + text: String, + position: LanguageServerPosition, rootURL _: URL, - completion _: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) throws { - throw unavailableLanguageServerError(for: fileURL) + guard supportsBuiltinLanguageServer(for: fileURL) else { + throw unavailableLanguageServerError(for: fileURL) + } + completion(.success(core.builtinLanguageHover( + fileURL: fileURL, + text: text, + position: position + ))) } func completions( fileURL: URL, - text _: String, - position _: LanguageServerPosition, + text: String, + position: LanguageServerPosition, rootURL _: URL, - completion _: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { - throw unavailableLanguageServerError(for: fileURL) + guard supportsBuiltinLanguageServer(for: fileURL) else { + throw unavailableLanguageServerError(for: fileURL) + } + completion(.success(core.builtinLanguageCompletions( + fileURL: fileURL, + text: text, + position: position + ) ?? [])) } func rename( @@ -340,6 +367,11 @@ final class LanguageToolingSessionManager: ObservableObject { ) } + private func supportsBuiltinLanguageServer(for fileURL: URL) -> Bool { + catalog.provider(for: fileURL)?.capabilities.contains(.languageServer) == true + && core.isAvailable + } + private func configureDebugCallbacks( _ session: any DebugAdapterSession, providerID: String diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index e6fcd0bc..6c29fdd2 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -33,6 +33,9 @@ pub enum CoreCommand { MarkdownRender, LspApplyTextEdits, LspPlainSnippet, + LspBuiltinCompletions, + LspBuiltinHover, + LspBuiltinNavigation, JavaRunConfigurations, RunConfigInspect, RunConfigGenerate, @@ -82,6 +85,9 @@ impl CoreCommand { "markdown.render" => Some(Self::MarkdownRender), "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), "lsp.plainSnippet" => Some(Self::LspPlainSnippet), + "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), + "lsp.builtinHover" => Some(Self::LspBuiltinHover), + "lsp.builtinNavigation" => Some(Self::LspBuiltinNavigation), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), "runConfig.generate" => Some(Self::RunConfigGenerate), diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index f4852932..b3a22b3d 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -136,6 +136,97 @@ pub struct PlainSnippetRequest { pub value: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinRequest { + pub file_path: String, + pub text: String, + pub position: LspPosition, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinNavigationRequest { + pub file_path: String, + pub text: String, + pub position: LspPosition, + pub method: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinCompletionResponse { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinCompletionItem { + pub label: String, + pub insert_text: String, + pub kind: Option, + pub detail: Option, + pub text_edit: LspTextEditResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspTextEditResponse { + pub range: LspRangeResponse, + pub new_text: String, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRangeResponse { + pub start: LspPositionResponse, + pub end: LspPositionResponse, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspPositionResponse { + pub line: i64, + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinHoverResponse { + pub hover: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinHover { + pub contents: String, + pub is_markdown: bool, + pub range: LspRangeResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinNavigationResponse { + pub locations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinLocation { + pub file_path: String, + pub range: LspRangeResponse, + pub is_read_only: bool, + pub display_path: Option, +} + +#[derive(Debug, Clone)] +struct IdentifierOccurrence { + value: String, + start: usize, + end: usize, + range: LspRangeResponse, +} + pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { let catalog = provider_catalog(workspace_root); serde_json::to_string(&catalog) @@ -180,6 +271,116 @@ pub fn plain_snippet(request: PlainSnippetRequest) -> TextResponse { } } +pub fn builtin_completions( + request: BuiltinRequest, +) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let prefix = identifier_prefix_at(&request.text, cursor); + let start_column = request.position.utf16_column - prefix.encode_utf16().count() as i64; + let replacement_range = LspRangeResponse { + start: LspPositionResponse { + line: request.position.line, + utf16_column: start_column.max(0), + }, + end: LspPositionResponse { + line: request.position.line, + utf16_column: request.position.utf16_column, + }, + }; + + let mut seen = BTreeMap::::new(); + for occurrence in identifier_occurrences(&request.text) { + if occurrence.value == prefix { + continue; + } + if !prefix.is_empty() && !occurrence.value.starts_with(&prefix) { + continue; + } + let kind = builtin_completion_kind(&request.text, occurrence.start); + seen.entry(occurrence.value).or_insert(kind); + } + + let items = seen + .into_iter() + .take(80) + .map(|(label, kind)| BuiltinCompletionItem { + insert_text: label.clone(), + label, + kind: Some(kind), + detail: Some("Current file symbol".to_string()), + text_edit: LspTextEditResponse { + range: replacement_range, + new_text: String::new(), + }, + }) + .map(|mut item| { + item.text_edit.new_text = item.insert_text.clone(); + item + }) + .collect(); + Ok(BuiltinCompletionResponse { items }) +} + +pub fn builtin_hover(request: BuiltinRequest) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let Some(identifier) = identifier_at(&request.text, cursor) else { + return Ok(BuiltinHoverResponse { hover: None }); + }; + Ok(BuiltinHoverResponse { + hover: Some(BuiltinHover { + contents: format!("`{}`", identifier.value), + is_markdown: true, + range: identifier.range, + }), + }) +} + +pub fn builtin_navigation( + request: BuiltinNavigationRequest, +) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let Some(identifier) = identifier_at(&request.text, cursor) else { + return Ok(BuiltinNavigationResponse { + locations: Vec::new(), + }); + }; + let mut occurrences: Vec<_> = identifier_occurrences(&request.text) + .into_iter() + .filter(|occurrence| occurrence.value == identifier.value) + .collect(); + + if request.method == "textDocument/definition" + || request.method == "textDocument/declaration" + || request.method == "textDocument/typeDefinition" + { + let declarations: Vec<_> = occurrences + .iter() + .filter(|occurrence| looks_like_declaration(&request.text, occurrence.start)) + .cloned() + .collect(); + if !declarations.is_empty() { + occurrences = declarations; + } + } else if request.method == "textDocument/implementation" { + occurrences.retain(|occurrence| occurrence.start != identifier.start); + } + + let locations = occurrences + .into_iter() + .take(200) + .map(|occurrence| BuiltinLocation { + file_path: request.file_path.clone(), + range: occurrence.range, + is_read_only: false, + display_path: None, + }) + .collect(); + Ok(BuiltinNavigationResponse { locations }) +} + pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { let mut diagnostics = Vec::new(); let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { @@ -371,6 +572,17 @@ fn normalized_key(value: &str, trim_dot: bool) -> String { value } +fn validate_file_path(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP builtin request requires a file path.", + )) + } else { + Ok(()) + } +} + fn utf16_position_to_byte_offset(text: &str, position: LspPosition) -> Result { if position.line < 0 || position.utf16_column < 0 { return Err(invalid_range_error()); @@ -441,6 +653,201 @@ fn byte_offset_for_utf16_column( contents_end } +fn byte_offset_to_lsp_position(text: &str, offset: usize) -> LspPositionResponse { + let offset = offset.min(text.len()); + let mut line = 0_i64; + let mut column = 0_i64; + for (index, character) in text.char_indices() { + if index >= offset { + break; + } + if character == '\n' { + line += 1; + column = 0; + } else { + column += character.len_utf16() as i64; + } + } + LspPositionResponse { + line, + utf16_column: column, + } +} + +fn range_for_offsets(text: &str, start: usize, end: usize) -> LspRangeResponse { + LspRangeResponse { + start: byte_offset_to_lsp_position(text, start), + end: byte_offset_to_lsp_position(text, end), + } +} + +fn identifier_occurrences(text: &str) -> Vec { + let mut values = Vec::new(); + let mut current_start: Option = None; + for (index, character) in text.char_indices() { + if is_identifier_character(character) { + if current_start.is_none() { + current_start = Some(index); + } + } else if let Some(start) = current_start.take() { + push_identifier(text, start, index, &mut values); + } + } + if let Some(start) = current_start { + push_identifier(text, start, text.len(), &mut values); + } + values +} + +fn push_identifier(text: &str, start: usize, end: usize, values: &mut Vec) { + let value = &text[start..end]; + if value.chars().next().is_some_and(is_identifier_start) + && !is_language_keyword(value) + && value.len() <= 120 + { + values.push(IdentifierOccurrence { + value: value.to_string(), + start, + end, + range: range_for_offsets(text, start, end), + }); + } +} + +fn identifier_at(text: &str, cursor: usize) -> Option { + identifier_occurrences(text) + .into_iter() + .find(|occurrence| occurrence.start <= cursor && cursor <= occurrence.end) +} + +fn identifier_prefix_at(text: &str, cursor: usize) -> String { + let mut start = cursor.min(text.len()); + while start > 0 { + let Some((previous_index, previous)) = text[..start].char_indices().next_back() else { + break; + }; + if !is_identifier_character(previous) { + break; + } + start = previous_index; + } + text[start..cursor.min(text.len())].to_string() +} + +fn is_identifier_start(character: char) -> bool { + character == '_' || character.is_alphabetic() +} + +fn is_identifier_character(character: char) -> bool { + character == '_' || character.is_alphanumeric() +} + +fn is_language_keyword(value: &str) -> bool { + matches!( + value, + "as" | "async" + | "await" + | "break" + | "case" + | "catch" + | "class" + | "const" + | "continue" + | "def" + | "default" + | "defer" + | "do" + | "else" + | "enum" + | "export" + | "extends" + | "false" + | "final" + | "fn" + | "for" + | "func" + | "function" + | "if" + | "impl" + | "import" + | "in" + | "interface" + | "let" + | "match" + | "mod" + | "mut" + | "nil" + | "null" + | "package" + | "private" + | "protected" + | "public" + | "return" + | "self" + | "static" + | "struct" + | "switch" + | "this" + | "throw" + | "throws" + | "trait" + | "true" + | "try" + | "type" + | "var" + | "while" + ) +} + +fn builtin_completion_kind(text: &str, start: usize) -> i32 { + if looks_like_declaration_with_keywords( + text, + start, + &["class", "struct", "enum", "interface", "trait"], + ) { + 7 + } else if looks_like_declaration_with_keywords(text, start, &["func", "function", "def", "fn"]) + { + 3 + } else { + 6 + } +} + +fn looks_like_declaration(text: &str, start: usize) -> bool { + looks_like_declaration_with_keywords( + text, + start, + &[ + "class", + "struct", + "enum", + "interface", + "trait", + "func", + "function", + "def", + "fn", + "let", + "var", + "const", + "type", + ], + ) +} + +fn looks_like_declaration_with_keywords(text: &str, start: usize, keywords: &[&str]) -> bool { + let line_start = text[..start].rfind('\n').map_or(0, |index| index + 1); + let prefix = &text[line_start..start]; + let tokens: Vec<&str> = prefix + .split(|character: char| !is_identifier_character(character)) + .filter(|token| !token.is_empty()) + .collect(); + tokens + .last() + .is_some_and(|token| keywords.iter().any(|keyword| keyword == token)) +} + fn snippet_plain_text(value: &str) -> String { let mut output = String::new(); let mut chars = value.chars().peekable(); @@ -704,4 +1111,74 @@ mod tests { assert_eq!(snippet_plain_text("print(${1:value})$0"), "print(value)"); assert_eq!(snippet_plain_text("${1:let} ${2:name} = $3"), "let name = "); } + + #[test] + fn builtin_completion_returns_current_file_identifiers_for_prefix() { + let response = builtin_completions(BuiltinRequest { + file_path: "/tmp/main.swift".to_string(), + text: "struct RocketShip {}\nlet rocketSpeed = Roc\n".to_string(), + position: LspPosition { + line: 1, + utf16_column: 19, + }, + }) + .unwrap(); + + assert!(response.items.iter().any(|item| item.label == "RocketShip")); + let item = response + .items + .iter() + .find(|item| item.label == "RocketShip") + .unwrap(); + assert_eq!(item.text_edit.range.start.utf16_column, 18); + assert_eq!(item.text_edit.new_text, "RocketShip"); + } + + #[test] + fn builtin_hover_returns_current_identifier_range() { + let response = builtin_hover(BuiltinRequest { + file_path: "/tmp/main.rs".to_string(), + text: "fn launch() {}\n".to_string(), + position: LspPosition { + line: 0, + utf16_column: 4, + }, + }) + .unwrap(); + + let hover = response.hover.unwrap(); + assert_eq!(hover.contents, "`launch`"); + assert_eq!(hover.range.start.utf16_column, 3); + assert_eq!(hover.range.end.utf16_column, 9); + } + + #[test] + fn builtin_navigation_prefers_declarations_and_finds_references() { + let text = "let service = 1\nprint(service)\n"; + let definitions = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/main.swift".to_string(), + text: text.to_string(), + position: LspPosition { + line: 1, + utf16_column: 8, + }, + method: "textDocument/definition".to_string(), + }) + .unwrap(); + assert_eq!(definitions.locations.len(), 1); + assert_eq!(definitions.locations[0].range.start.line, 0); + assert_eq!(definitions.locations[0].range.start.utf16_column, 4); + + let references = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/main.swift".to_string(), + text: text.to_string(), + position: LspPosition { + line: 1, + utf16_column: 8, + }, + method: "textDocument/references".to_string(), + }) + .unwrap(); + assert_eq!(references.locations.len(), 2); + } } diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 54c75576..198f4e22 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -294,6 +294,51 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspBuiltinCompletions => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP completion request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::builtin_completions) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP completion response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspBuiltinHover => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP hover request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::builtin_hover) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP hover response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspBuiltinNavigation => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP navigation request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::builtin_navigation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP navigation response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::JavaRunConfigurations => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index fee4d098..3216cebd 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -83,3 +83,43 @@ guard let snippetData = snippetResponse.data(using: .utf8), } print("Rust Core LSP text edit and snippet bridge passed") + +let builtinCompletionRequest = """ +{"id":"lsp-builtin-completion-test","command":"lsp.builtinCompletions","payload":{"filePath":"/tmp/main.swift","text":"struct RocketShip {}\\nlet value = Roc\\n","position":{"line":1,"utf16Column":15}}} +""" +guard let builtinCompletionPointer = builtinCompletionRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core builtin completion bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(builtinCompletionPointer) } + +let builtinCompletionResponse = String(cString: builtinCompletionPointer) +guard let builtinCompletionData = builtinCompletionResponse.data(using: .utf8), + let builtinCompletionEnvelope = try? JSONSerialization.jsonObject(with: builtinCompletionData) as? [String: Any], + let builtinCompletionPayload = builtinCompletionEnvelope["data"] as? [String: Any], + let builtinCompletionItems = builtinCompletionPayload["items"] as? [[String: Any]], + builtinCompletionItems.contains(where: { $0["label"] as? String == "RocketShip" }) else { + fputs("Unexpected Rust Core builtin completion response: \(builtinCompletionResponse)\n", stderr) + exit(1) +} + +let builtinNavigationRequest = """ +{"id":"lsp-builtin-navigation-test","command":"lsp.builtinNavigation","payload":{"filePath":"/tmp/main.swift","text":"let service = 1\\nprint(service)\\n","position":{"line":1,"utf16Column":8},"method":"textDocument/definition"}} +""" +guard let builtinNavigationPointer = builtinNavigationRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core builtin navigation bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(builtinNavigationPointer) } + +let builtinNavigationResponse = String(cString: builtinNavigationPointer) +guard let builtinNavigationData = builtinNavigationResponse.data(using: .utf8), + let builtinNavigationEnvelope = try? JSONSerialization.jsonObject(with: builtinNavigationData) as? [String: Any], + let builtinNavigationPayload = builtinNavigationEnvelope["data"] as? [String: Any], + let builtinNavigationLocations = builtinNavigationPayload["locations"] as? [[String: Any]], + builtinNavigationLocations.count == 1 else { + fputs("Unexpected Rust Core builtin navigation response: \(builtinNavigationResponse)\n", stderr) + exit(1) +} + +print("Rust Core builtin LSP bridge passed") diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 59557b88..c1307840 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -72,6 +72,9 @@ stable error code and a user-facing message: | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | | `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | | `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | +| `lsp.builtinCompletions` | Return lightweight current-file identifier completions | +| `lsp.builtinHover` | Return lightweight current-symbol hover text | +| `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | | `java.runConfigurations` | Scan Java sources for main classes and return Maven/Spring run configurations | | `java.codeVision` | Return Java declaration usage counts for editor code vision | | `java.className` | Resolve a Java source package and simple name into a runtime class name | @@ -183,6 +186,16 @@ details `invalidRange`. Successful responses return `{ "text": string }`. after removing LSP tab stops and replacing simple placeholder defaults such as `${1:name}` with `name`. +`lsp.builtinCompletions`, `lsp.builtinHover`, and `lsp.builtinNavigation` are +the no-process lightweight language path. They accept current-file text, an +absolute `filePath`, and a zero-based LSP position. Completion returns +current-file identifiers with text edits for the active prefix. Hover returns +the current identifier as markdown. Navigation returns current-file locations; +definition prefers declaration-looking occurrences, while references returns +all matching identifier occurrences. These commands are deliberately +text-level fallbacks; precise type-aware behavior belongs to a started language +server. + The `history.*` commands accept an adapter-selected `storageRoot`; history metadata never stores an absolute workspace or storage path. `history.record` accepts `workspaceRoot`, a relative `path`, a `reason`, and optional UTF-8 From 1ef59a2295935d366995b924fd19adf222226b1c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 00:09:57 +0800 Subject: [PATCH 07/38] Add Rust LSP client state core --- rust/lithe-core/src/command.rs | 10 + rust/lithe-core/src/lsp.rs | 885 ++++++++++++++++++++++- rust/lithe-core/src/runtime.rs | 86 +++ scripts/RustCoreBridgeVerification.swift | 55 ++ shared/contracts/rust-core-api.md | 19 + 5 files changed, 1053 insertions(+), 2 deletions(-) diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index 6c29fdd2..faf586fe 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -36,6 +36,11 @@ pub enum CoreCommand { LspBuiltinCompletions, LspBuiltinHover, LspBuiltinNavigation, + LspClientInitialize, + LspClientOpenDocument, + LspClientChangeDocument, + LspClientRequest, + LspClientApplyServerMessage, JavaRunConfigurations, RunConfigInspect, RunConfigGenerate, @@ -88,6 +93,11 @@ impl CoreCommand { "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), "lsp.builtinHover" => Some(Self::LspBuiltinHover), "lsp.builtinNavigation" => Some(Self::LspBuiltinNavigation), + "lsp.clientInitialize" => Some(Self::LspClientInitialize), + "lsp.clientOpenDocument" => Some(Self::LspClientOpenDocument), + "lsp.clientChangeDocument" => Some(Self::LspClientChangeDocument), + "lsp.clientRequest" => Some(Self::LspClientRequest), + "lsp.clientApplyServerMessage" => Some(Self::LspClientApplyServerMessage), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), "runConfig.generate" => Some(Self::RunConfigGenerate), diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index b3a22b3d..4e212ddc 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -176,14 +177,14 @@ pub struct LspTextEditResponse { pub new_text: String, } -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspRangeResponse { pub start: LspPositionResponse, pub end: LspPositionResponse, } -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspPositionResponse { pub line: i64, @@ -227,6 +228,131 @@ struct IdentifierOccurrence { range: LspRangeResponse, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientState { + #[serde(default = "default_next_request_id")] + pub next_request_id: u64, + #[serde(default)] + pub initialized: bool, + #[serde(default)] + pub server_capabilities: Vec, + #[serde(default)] + pub open_documents: BTreeMap, + #[serde(default)] + pub pending_requests: BTreeMap, + #[serde(default)] + pub diagnostics: BTreeMap>, +} + +impl Default for LspClientState { + fn default() -> Self { + Self { + next_request_id: default_next_request_id(), + initialized: false, + server_capabilities: Vec::new(), + open_documents: BTreeMap::new(), + pending_requests: BTreeMap::new(), + diagnostics: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDocument { + pub uri: String, + pub language_id: String, + pub version: i64, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnostic { + pub range: LspRangeResponse, + pub severity: Option, + pub message: String, + pub source: Option, + pub code: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInitializeRequest { + #[serde(default)] + pub state: LspClientState, + pub root_uri: String, + #[serde(default)] + pub process_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientOpenDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub language_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientChangeDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientFeatureRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub method: String, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApplyServerMessageRequest { + #[serde(default)] + pub state: LspClientState, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientResponse { + pub state: LspClientState, + pub messages: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientEvent { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { let catalog = provider_catalog(workspace_root); serde_json::to_string(&catalog) @@ -381,6 +507,226 @@ pub fn builtin_navigation( Ok(BuiltinNavigationResponse { locations }) } +pub fn client_initialize(request: ClientInitializeRequest) -> Result { + validate_uri(&request.root_uri)?; + let mut state = request.state; + let id = allocate_request(&mut state, "initialize"); + let message = json_rpc_request( + &id, + "initialize", + json!({ + "processId": request.process_id, + "rootUri": request.root_uri, + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true, + "documentationFormat": ["markdown", "plaintext"] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": ["markdown", "plaintext"] + }, + "definition": { "dynamicRegistration": true }, + "declaration": { "dynamicRegistration": true }, + "typeDefinition": { "dynamicRegistration": true }, + "implementation": { "dynamicRegistration": true }, + "references": { "dynamicRegistration": true }, + "rename": { "dynamicRegistration": true }, + "formatting": { "dynamicRegistration": true }, + "codeAction": { + "dynamicRegistration": true, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": ["quickfix", "refactor", "source"] + } + } + }, + "publishDiagnostics": { + "relatedInformation": true + } + }, + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true + }, + "executeCommand": { "dynamicRegistration": true } + } + } + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_open_document( + request: ClientOpenDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let document = LspClientDocument { + uri: request.uri.clone(), + language_id: request.language_id, + version: 1, + text: request.text, + }; + let message = json_rpc_notification( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": document.uri, + "languageId": document.language_id, + "version": document.version, + "text": document.text + } + }), + )?; + state.open_documents.insert(request.uri, document); + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_change_document( + request: ClientChangeDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.get_mut(&request.uri) else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot change a document that is not open in the LSP client.", + )); + }; + document.version += 1; + document.text = request.text; + let message = json_rpc_notification( + "textDocument/didChange", + json!({ + "textDocument": { + "uri": document.uri, + "version": document.version + }, + "contentChanges": [{ + "text": document.text + }] + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_feature_request( + request: ClientFeatureRequest, +) -> Result { + validate_uri(&request.uri)?; + validate_lsp_method(&request.method)?; + let params = feature_request_params(&request)?; + let uri = request.uri.clone(); + let method = request.method.clone(); + let mut state = request.state; + if !state.open_documents.contains_key(&uri) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot request LSP features for a document that is not open.", + )); + } + let id = allocate_request(&mut state, &method); + let message = json_rpc_request(&id, &method, params)?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_apply_server_message( + request: ClientApplyServerMessageRequest, +) -> Result { + let mut state = request.state; + let message: Value = serde_json::from_str(&request.message).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP server JSON message") + .with_details(error.to_string()) + })?; + let mut responses = Vec::new(); + let mut events = Vec::new(); + + if let Some(method) = message.get("method").and_then(Value::as_str) { + match method { + "textDocument/publishDiagnostics" => { + if let Some(params) = message.get("params") { + let uri = params + .get("uri") + .and_then(Value::as_str) + .unwrap_or_default(); + validate_uri(uri)?; + let diagnostics = parse_diagnostics(params.get("diagnostics")); + state + .diagnostics + .insert(uri.to_string(), diagnostics.clone()); + events.push(LspClientEvent { + kind: "diagnostics".to_string(), + request_id: None, + method: None, + uri: Some(uri.to_string()), + diagnostics: Some(diagnostics), + result: None, + error: None, + }); + } + } + "client/registerCapability" => { + apply_dynamic_registration(&mut state, &message); + if let Some(id) = lsp_message_id(&message) { + responses.push(json_rpc_result(&id, Value::Null)?); + } + } + "client/unregisterCapability" => { + apply_dynamic_unregistration(&mut state, &message); + if let Some(id) = lsp_message_id(&message) { + responses.push(json_rpc_result(&id, Value::Null)?); + } + } + _ => { + events.push(LspClientEvent { + kind: "notification".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + diagnostics: None, + result: message.get("params").cloned(), + error: None, + }); + } + } + } else if let Some(id) = lsp_message_id(&message) { + let pending = state.pending_requests.remove(&id); + if pending.as_deref() == Some("initialize") { + if let Some(result) = message.get("result") { + state.server_capabilities = feature_names_from_capabilities( + result.get("capabilities").unwrap_or(&Value::Null), + ); + state.initialized = true; + responses.push(json_rpc_notification("initialized", json!({}))?); + } + } + events.push(LspClientEvent { + kind: if message.get("error").is_some() { + "error".to_string() + } else { + "response".to_string() + }, + request_id: Some(id), + method: pending, + uri: None, + diagnostics: None, + result: message.get("result").cloned(), + error: message.get("error").map(|value| value.to_string()), + }); + } + + Ok(client_response(state, responses, events)) +} + pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { let mut diagnostics = Vec::new(); let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { @@ -465,6 +811,369 @@ fn default_config_version() -> u32 { 1 } +fn default_next_request_id() -> u64 { + 1 +} + +fn client_response( + state: LspClientState, + messages: Vec, + events: Vec, +) -> LspClientResponse { + LspClientResponse { + state, + messages, + events, + } +} + +fn allocate_request(state: &mut LspClientState, method: &str) -> String { + let id = state.next_request_id.to_string(); + state.next_request_id += 1; + state + .pending_requests + .insert(id.clone(), method.to_string()); + id +} + +fn json_rpc_request(id: &str, method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) +} + +fn json_rpc_notification(method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + })) +} + +fn json_rpc_result(id: &str, result: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + })) +} + +fn encode_json_rpc(value: Value) -> Result { + serde_json::to_string(&value).map_err(|error| { + CoreError::new(ErrorCode::Unknown, "Could not encode LSP JSON-RPC message") + .with_details(error.to_string()) + }) +} + +fn validate_uri(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP request requires a valid URI.", + )) + } else { + Ok(()) + } +} + +fn validate_lsp_method(method: &str) -> Result<(), CoreError> { + match method { + "textDocument/completion" + | "textDocument/hover" + | "textDocument/definition" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation" + | "textDocument/references" + | "textDocument/rename" + | "textDocument/formatting" + | "textDocument/codeAction" + | "completionItem/resolve" + | "codeAction/resolve" + | "workspace/executeCommand" => Ok(()), + _ => Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported LSP client request method.", + ) + .with_details(method.to_string())), + } +} + +fn feature_request_params(request: &ClientFeatureRequest) -> Result { + let text_document = json!({ "uri": request.uri }); + match request.method.as_str() { + "textDocument/completion" + | "textDocument/hover" + | "textDocument/definition" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?) + })), + "textDocument/references" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?), + "context": { "includeDeclaration": true } + })), + "textDocument/rename" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?), + "newName": request.new_name.clone().unwrap_or_default() + })), + "textDocument/formatting" => Ok(json!({ + "textDocument": text_document, + "options": { + "tabSize": 4, + "insertSpaces": true, + "trimTrailingWhitespace": true, + "insertFinalNewline": true, + "trimFinalNewlines": true + } + })), + _ => Ok(json!({ "textDocument": text_document })), + } +} + +fn required_position(request: &ClientFeatureRequest) -> Result { + request.position.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a text document position.", + ) + }) +} + +fn lsp_position_json(position: LspPosition) -> Value { + json!({ + "line": position.line, + "character": position.utf16_column + }) +} + +fn lsp_message_id(message: &Value) -> Option { + message.get("id").and_then(|id| match id { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + +fn parse_diagnostics(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(LspClientDiagnostic { + range: parse_lsp_range(item.get("range")?)?, + severity: item.get("severity").and_then(Value::as_i64), + message: item + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + source: item + .get("source") + .and_then(Value::as_str) + .map(str::to_string), + code: item.get("code").and_then(|code| match code { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_lsp_range(value: &Value) -> Option { + Some(LspRangeResponse { + start: parse_lsp_position(value.get("start")?)?, + end: parse_lsp_position(value.get("end")?)?, + }) +} + +fn parse_lsp_position(value: &Value) -> Option { + Some(LspPositionResponse { + line: value.get("line")?.as_i64()?, + utf16_column: value.get("character")?.as_i64()?, + }) +} + +fn feature_names_from_capabilities(capabilities: &Value) -> Vec { + let mut values = Vec::new(); + add_capability( + &mut values, + capabilities, + "definitionProvider", + "definition", + ); + add_capability( + &mut values, + capabilities, + "declarationProvider", + "declaration", + ); + add_capability( + &mut values, + capabilities, + "typeDefinitionProvider", + "typeDefinition", + ); + add_capability( + &mut values, + capabilities, + "implementationProvider", + "implementation", + ); + add_capability( + &mut values, + capabilities, + "referencesProvider", + "references", + ); + add_capability(&mut values, capabilities, "hoverProvider", "hover"); + add_capability( + &mut values, + capabilities, + "completionProvider", + "completion", + ); + add_capability(&mut values, capabilities, "renameProvider", "rename"); + add_capability( + &mut values, + capabilities, + "documentFormattingProvider", + "formatting", + ); + add_capability( + &mut values, + capabilities, + "codeActionProvider", + "codeActions", + ); + add_capability( + &mut values, + capabilities, + "executeCommandProvider", + "executeCommand", + ); + if capabilities + .get("completionProvider") + .and_then(|value| value.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + insert_unique(&mut values, "completionResolve"); + } + if capabilities + .get("codeActionProvider") + .and_then(|value| value.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + insert_unique(&mut values, "codeActionResolve"); + } + values +} + +fn add_capability(values: &mut Vec, capabilities: &Value, key: &str, feature: &str) { + match capabilities.get(key) { + Some(Value::Bool(true)) => insert_unique(values, feature), + Some(Value::Object(_)) => insert_unique(values, feature), + _ => {} + } +} + +fn apply_dynamic_registration(state: &mut LspClientState, message: &Value) { + let Some(registrations) = message + .get("params") + .and_then(|params| params.get("registrations")) + .and_then(Value::as_array) + else { + return; + }; + for registration in registrations { + if let Some(feature) = registration + .get("method") + .and_then(Value::as_str) + .and_then(feature_name_for_method) + { + insert_unique(&mut state.server_capabilities, feature); + } + if registration + .get("registerOptions") + .and_then(|options| options.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") + { + insert_unique(&mut state.server_capabilities, "completionResolve"); + } + if registration.get("method").and_then(Value::as_str) == Some("textDocument/codeAction") + { + insert_unique(&mut state.server_capabilities, "codeActionResolve"); + } + } + } +} + +fn apply_dynamic_unregistration(state: &mut LspClientState, message: &Value) { + let Some(unregistrations) = message + .get("params") + .and_then(|params| { + params + .get("unregistrations") + .or_else(|| params.get("unregisterations")) + }) + .and_then(Value::as_array) + else { + return; + }; + for unregistration in unregistrations { + if let Some(feature) = unregistration + .get("method") + .and_then(Value::as_str) + .and_then(feature_name_for_method) + { + state + .server_capabilities + .retain(|existing| existing != feature); + } + } +} + +fn feature_name_for_method(method: &str) -> Option<&'static str> { + match method { + "textDocument/definition" => Some("definition"), + "textDocument/declaration" => Some("declaration"), + "textDocument/typeDefinition" => Some("typeDefinition"), + "textDocument/implementation" => Some("implementation"), + "textDocument/references" => Some("references"), + "textDocument/hover" => Some("hover"), + "textDocument/completion" => Some("completion"), + "textDocument/rename" => Some("rename"), + "textDocument/formatting" => Some("formatting"), + "textDocument/codeAction" => Some("codeActions"), + "workspace/executeCommand" => Some("executeCommand"), + _ => None, + } +} + +fn insert_unique(values: &mut Vec, value: &str) { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } +} + impl LspProviderPatch { fn apply(&mut self, patch: LspProviderPatch) { if patch.display_name.is_some() { @@ -1181,4 +1890,176 @@ mod tests { .unwrap(); assert_eq!(references.locations.len(), 2); } + + #[test] + fn client_core_initializes_and_applies_server_capabilities() { + let initialized = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: "file:///tmp/project".to_string(), + process_id: Some(42), + }) + .unwrap(); + assert_eq!( + initialized.state.pending_requests.get("1").unwrap(), + "initialize" + ); + let initialize_message: Value = + serde_json::from_str(&initialized.messages[0]).expect("initialize JSON"); + assert_eq!(initialize_message["method"], "initialize"); + assert_eq!( + initialize_message["params"]["rootUri"], + "file:///tmp/project" + ); + + let applied = client_apply_server_message(ClientApplyServerMessageRequest { + state: initialized.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "capabilities": { + "definitionProvider": true, + "hoverProvider": true, + "completionProvider": { "resolveProvider": true }, + "codeActionProvider": { "resolveProvider": true } + } + } + }"# + .to_string(), + }) + .unwrap(); + + assert!(applied.state.initialized); + assert!(applied.state.pending_requests.is_empty()); + assert!(applied + .state + .server_capabilities + .contains(&"definition".to_string())); + assert!(applied + .state + .server_capabilities + .contains(&"completionResolve".to_string())); + assert_eq!(applied.messages.len(), 1); + let initialized_notification: Value = + serde_json::from_str(&applied.messages[0]).expect("initialized JSON"); + assert_eq!(initialized_notification["method"], "initialized"); + } + + #[test] + fn client_core_tracks_documents_and_feature_requests() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() {}\n".to_string(), + }) + .unwrap(); + assert_eq!( + opened + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .version, + 1 + ); + let did_open: Value = serde_json::from_str(&opened.messages[0]).unwrap(); + assert_eq!(did_open["method"], "textDocument/didOpen"); + + let changed = client_change_document(ClientChangeDocumentRequest { + state: opened.state, + uri: "file:///tmp/project/main.rs".to_string(), + text: "fn main() { launch(); }\n".to_string(), + }) + .unwrap(); + assert_eq!( + changed + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .version, + 2 + ); + let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); + assert_eq!(did_change["method"], "textDocument/didChange"); + + let requested = client_feature_request(ClientFeatureRequest { + state: changed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/definition".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 12, + }), + new_name: None, + }) + .unwrap(); + assert_eq!( + requested.state.pending_requests.get("1").unwrap(), + "textDocument/definition" + ); + let request_message: Value = serde_json::from_str(&requested.messages[0]).unwrap(); + assert_eq!(request_message["method"], "textDocument/definition"); + assert_eq!(request_message["params"]["position"]["character"], 12); + } + + #[test] + fn client_core_applies_diagnostics_and_dynamic_registrations() { + let state = LspClientState::default(); + let diagnostics = client_apply_server_message(ClientApplyServerMessageRequest { + state, + message: r#"{ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": "file:///tmp/project/main.py", + "diagnostics": [{ + "range": { + "start": { "line": 2, "character": 4 }, + "end": { "line": 2, "character": 9 } + }, + "severity": 1, + "source": "pyright", + "code": "reportGeneralTypeIssues", + "message": "Example diagnostic" + }] + } + }"# + .to_string(), + }) + .unwrap(); + let stored = diagnostics + .state + .diagnostics + .get("file:///tmp/project/main.py") + .unwrap(); + assert_eq!(stored[0].message, "Example diagnostic"); + assert_eq!(stored[0].range.start.utf16_column, 4); + assert_eq!(diagnostics.events[0].kind, "diagnostics"); + + let registered = client_apply_server_message(ClientApplyServerMessageRequest { + state: diagnostics.state, + message: r#"{ + "jsonrpc": "2.0", + "id": 77, + "method": "client/registerCapability", + "params": { + "registrations": [{ + "id": "formatting", + "method": "textDocument/formatting", + "registerOptions": {} + }] + } + }"# + .to_string(), + }) + .unwrap(); + assert!(registered + .state + .server_capabilities + .contains(&"formatting".to_string())); + let response: Value = serde_json::from_str(®istered.messages[0]).unwrap(); + assert_eq!(response["id"], "77"); + } } diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 198f4e22..3cec3a5f 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -339,6 +339,92 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspClientInitialize => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP initialize request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_initialize) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientOpenDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP open document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_open_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientChangeDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP change document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_change_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP feature request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_feature_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientApplyServerMessage => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP server message request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_apply_server_message) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::JavaRunConfigurations => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index 3216cebd..5d4e3816 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -123,3 +123,58 @@ guard let builtinNavigationData = builtinNavigationResponse.data(using: .utf8), } print("Rust Core builtin LSP bridge passed") + +let clientInitializeRequest = """ +{"id":"lsp-client-init-test","command":"lsp.clientInitialize","payload":{"state":{},"rootUri":"file:///tmp/project","processId":42}} +""" +guard let clientInitializePointer = clientInitializeRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP client initialize bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(clientInitializePointer) } + +let clientInitializeResponse = String(cString: clientInitializePointer) +guard let clientInitializeData = clientInitializeResponse.data(using: .utf8), + let clientInitializeEnvelope = try? JSONSerialization.jsonObject(with: clientInitializeData) as? [String: Any], + let clientInitializePayload = clientInitializeEnvelope["data"] as? [String: Any], + let clientMessages = clientInitializePayload["messages"] as? [String], + let initializeMessageData = clientMessages.first?.data(using: .utf8), + let initializeMessage = try? JSONSerialization.jsonObject(with: initializeMessageData) as? [String: Any], + initializeMessage["method"] as? String == "initialize", + let clientState = clientInitializePayload["state"] as? [String: Any] else { + fputs("Unexpected Rust Core LSP client initialize response: \(clientInitializeResponse)\n", stderr) + exit(1) +} + +let stateData = try JSONSerialization.data(withJSONObject: clientState) +let serverMessage = #"{"jsonrpc":"2.0","id":"1","result":{"capabilities":{"definitionProvider":true,"completionProvider":{"resolveProvider":true}}}}"# +let stateObject = try JSONSerialization.jsonObject(with: stateData) +let clientApplyRequestData = try JSONSerialization.data(withJSONObject: [ + "id": "lsp-client-apply-test", + "command": "lsp.clientApplyServerMessage", + "payload": [ + "state": stateObject, + "message": serverMessage + ] +]) +let clientApplyRequest = String(data: clientApplyRequestData, encoding: .utf8)! +guard let clientApplyPointer = clientApplyRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP client apply bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(clientApplyPointer) } + +let clientApplyResponse = String(cString: clientApplyPointer) +guard let clientApplyData = clientApplyResponse.data(using: .utf8), + let clientApplyEnvelope = try? JSONSerialization.jsonObject(with: clientApplyData) as? [String: Any], + let clientApplyPayload = clientApplyEnvelope["data"] as? [String: Any], + let appliedState = clientApplyPayload["state"] as? [String: Any], + appliedState["initialized"] as? Bool == true, + let serverCapabilities = appliedState["serverCapabilities"] as? [String], + serverCapabilities.contains("definition"), + serverCapabilities.contains("completionResolve") else { + fputs("Unexpected Rust Core LSP client apply response: \(clientApplyResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP client bridge passed") diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c1307840..f0237b3a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -75,6 +75,11 @@ stable error code and a user-facing message: | `lsp.builtinCompletions` | Return lightweight current-file identifier completions | | `lsp.builtinHover` | Return lightweight current-symbol hover text | | `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | +| `lsp.clientInitialize` | Create an LSP initialize JSON-RPC request and client state | +| `lsp.clientOpenDocument` | Track an open document and emit `textDocument/didOpen` | +| `lsp.clientChangeDocument` | Track a full-text document change and emit `textDocument/didChange` | +| `lsp.clientRequest` | Emit a typed LSP feature request and record the pending request | +| `lsp.clientApplyServerMessage` | Apply LSP server responses, diagnostics, and dynamic registrations | | `java.runConfigurations` | Scan Java sources for main classes and return Maven/Spring run configurations | | `java.codeVision` | Return Java declaration usage counts for editor code vision | | `java.className` | Resolve a Java source package and simple name into a runtime class name | @@ -196,6 +201,20 @@ all matching identifier occurrences. These commands are deliberately text-level fallbacks; precise type-aware behavior belongs to a started language server. +`lsp.client*` commands are the transport-independent LSP client core. The +platform adapter owns the process/stdin/stdout transport and passes a +serialized `state` object through these commands. Responses return +`{ "state": object, "messages": string[], "events": [] }`; `messages` are raw +JSON-RPC payloads for the adapter to frame and write to the language server. +`lsp.clientInitialize` records the pending initialize request and emits +`initialize`. `lsp.clientOpenDocument` and `lsp.clientChangeDocument` maintain +document versions and emit full-text sync notifications. `lsp.clientRequest` +supports completion, hover, definition/declaration/typeDefinition, +implementation, references, rename, formatting, code action, resolve, and +execute-command methods. `lsp.clientApplyServerMessage` parses server +responses, derives feature names from initialize capabilities, stores +`publishDiagnostics`, and handles dynamic register/unregister notifications. + The `history.*` commands accept an adapter-selected `storageRoot`; history metadata never stores an absolute workspace or storage path. `history.record` accepts `workspaceRoot`, a relative `path`, a `reason`, and optional UTF-8 From 3de53b7c8577d53822070d4ae791803bc336645e Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 00:15:34 +0800 Subject: [PATCH 08/38] Move LSP launch metadata into Rust catalog --- .../Lithe/Core/Ports/LanguageTooling.swift | 10 ++- .../RustLanguageProviderCatalogSource.swift | 16 +++- .../resources/lsp/language-providers.json | 74 ++++++++++++++++--- rust/lithe-core/src/lsp.rs | 43 ++++++++++- scripts/RustCoreBridgeVerification.swift | 5 +- shared/contracts/rust-core-api.md | 6 ++ 6 files changed, 139 insertions(+), 15 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index bdc8168c..da8a49c9 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -56,6 +56,11 @@ enum ToolingActivationPolicy: String, Codable, Hashable, Sendable { case always } +struct LanguageServerLaunchDescriptor: Hashable, Sendable { + let executableNames: [String] + let arguments: [String] +} + struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { let id: String let displayName: String @@ -67,6 +72,7 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { let languageIdentifier: String? let languageIdentifiersByExtension: [String: String] let languageIdentifiersByFileName: [String: String] + let languageServerLaunch: LanguageServerLaunchDescriptor? init( id: String, @@ -78,7 +84,8 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { activationPolicy: ToolingActivationPolicy, languageIdentifier: String? = nil, languageIdentifiersByExtension: [String: String] = [:], - languageIdentifiersByFileName: [String: String] = [:] + languageIdentifiersByFileName: [String: String] = [:], + languageServerLaunch: LanguageServerLaunchDescriptor? = nil ) { self.id = id self.displayName = displayName @@ -98,6 +105,7 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { ($0.key.lowercased(), $0.value) } ) + self.languageServerLaunch = languageServerLaunch } func handles(fileURL: URL) -> Bool { diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift index 44ea941d..b7bee0e1 100644 --- a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift +++ b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift @@ -10,6 +10,18 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { let providers: [ProviderPayload] } + private struct LanguageServerLaunchPayload: Decodable { + let executableNames: [String] + let arguments: [String] + + func makeDescriptor() -> LanguageServerLaunchDescriptor { + LanguageServerLaunchDescriptor( + executableNames: executableNames, + arguments: arguments + ) + } + } + private struct ProviderPayload: Decodable { let id: String let displayName: String @@ -21,6 +33,7 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { let languageId: String? let languageIdsByExtension: [String: String] let languageIdsByFileName: [String: String] + let languageServerLaunch: LanguageServerLaunchPayload? func makeDescriptor() -> LanguageProviderDescriptor { LanguageProviderDescriptor( @@ -33,7 +46,8 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { activationPolicy: activationPolicy, languageIdentifier: languageId, languageIdentifiersByExtension: languageIdsByExtension, - languageIdentifiersByFileName: languageIdsByFileName + languageIdentifiersByFileName: languageIdsByFileName, + languageServerLaunch: languageServerLaunch?.makeDescriptor() ) } } diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json index fcb05692..d1cf8036 100644 --- a/rust/lithe-core/resources/lsp/language-providers.json +++ b/rust/lithe-core/resources/lsp/language-providers.json @@ -7,7 +7,11 @@ "fileExtensions": ["java"], "capabilities": ["run", "languageServer", "formatting", "testing"], "activationPolicy": "onDemand", - "languageId": "java" + "languageId": "java", + "languageServerLaunch": { + "executableNames": ["jdtls"], + "arguments": [] + } }, { "id": "go", @@ -15,7 +19,11 @@ "fileExtensions": ["go"], "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], "activationPolicy": "onDemand", - "languageId": "go" + "languageId": "go", + "languageServerLaunch": { + "executableNames": ["gopls"], + "arguments": [] + } }, { "id": "python", @@ -23,7 +31,11 @@ "fileExtensions": ["py", "pyw"], "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], "activationPolicy": "onDemand", - "languageId": "python" + "languageId": "python", + "languageServerLaunch": { + "executableNames": ["basedpyright-langserver", "pyright-langserver"], + "arguments": ["--stdio"] + } }, { "id": "node", @@ -32,6 +44,10 @@ "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], "activationPolicy": "onDemand", "languageId": "javascript", + "languageServerLaunch": { + "executableNames": ["typescript-language-server"], + "arguments": ["--stdio"] + }, "languageIdsByExtension": { "jsx": "javascriptreact", "ts": "typescript", @@ -44,7 +60,11 @@ "fileExtensions": ["rs"], "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], "activationPolicy": "onDemand", - "languageId": "rust" + "languageId": "rust", + "languageServerLaunch": { + "executableNames": ["rust-analyzer"], + "arguments": [] + } }, { "id": "clangd", @@ -53,6 +73,10 @@ "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", "languageId": "cpp", + "languageServerLaunch": { + "executableNames": ["clangd"], + "arguments": [] + }, "languageIdsByExtension": { "c": "c", "h": "c", @@ -82,7 +106,11 @@ "fileExtensions": ["swift"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "swift" + "languageId": "swift", + "languageServerLaunch": { + "executableNames": ["sourcekit-lsp"], + "arguments": [] + } }, { "id": "kotlin", @@ -90,7 +118,11 @@ "fileExtensions": ["kt", "kts"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "kotlin" + "languageId": "kotlin", + "languageServerLaunch": { + "executableNames": ["kotlin-language-server"], + "arguments": [] + } }, { "id": "scala", @@ -98,7 +130,11 @@ "fileExtensions": ["scala", "sc"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "scala" + "languageId": "scala", + "languageServerLaunch": { + "executableNames": ["metals"], + "arguments": [] + } }, { "id": "groovy", @@ -115,7 +151,11 @@ "fileNames": ["Rakefile", "Gemfile"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "ruby" + "languageId": "ruby", + "languageServerLaunch": { + "executableNames": ["ruby-lsp"], + "arguments": [] + } }, { "id": "php", @@ -123,7 +163,11 @@ "fileExtensions": ["php", "phtml"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "php" + "languageId": "php", + "languageServerLaunch": { + "executableNames": ["intelephense", "phpactor"], + "arguments": [] + } }, { "id": "dart", @@ -147,7 +191,11 @@ "fileExtensions": ["sh", "bash", "zsh", "fish", "ksh"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "shellscript" + "languageId": "shellscript", + "languageServerLaunch": { + "executableNames": ["bash-language-server"], + "arguments": ["start"] + } }, { "id": "powershell", @@ -219,7 +267,11 @@ "fileExtensions": ["yml", "yaml"], "capabilities": ["languageServer", "formatting"], "activationPolicy": "onDemand", - "languageId": "yaml" + "languageId": "yaml", + "languageServerLaunch": { + "executableNames": ["yaml-language-server"], + "arguments": ["--stdio"] + } }, { "id": "xml", diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index 4e212ddc..16109dbc 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -36,6 +36,15 @@ pub struct LspProviderDescriptor { pub language_id: Option, pub language_ids_by_extension: BTreeMap, pub language_ids_by_file_name: BTreeMap, + pub language_server_launch: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspServerLaunchDescriptor { + pub executable_names: Vec, + #[serde(default)] + pub arguments: Vec, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] @@ -93,6 +102,8 @@ struct LspProviderPatch { #[serde(default)] language_ids_by_file_name: Option>, #[serde(default)] + language_server_launch: Option, + #[serde(default)] disabled: bool, } @@ -1203,6 +1214,9 @@ impl LspProviderPatch { if patch.language_ids_by_file_name.is_some() { self.language_ids_by_file_name = patch.language_ids_by_file_name; } + if patch.language_server_launch.is_some() { + self.language_server_launch = patch.language_server_launch; + } self.disabled = patch.disabled; } } @@ -1240,6 +1254,7 @@ impl LspProviderDescriptor { patch.language_ids_by_file_name.unwrap_or_default(), false, ), + language_server_launch: patch.language_server_launch, } } } @@ -1658,6 +1673,19 @@ mod tests { clangd.language_ids_by_extension.get("m"), Some(&"objective-c".to_string()) ); + let swift = catalog + .providers + .iter() + .find(|provider| provider.id == "swift") + .expect("swift provider should exist"); + let swift_launch = swift + .language_server_launch + .as_ref() + .expect("swift launch descriptor should exist"); + assert_eq!( + swift_launch.executable_names, + vec!["sourcekit-lsp".to_string()] + ); } #[test] @@ -1679,7 +1707,11 @@ mod tests { }, { "id": "swift", - "fileExtensions": ["swift", "swiftinterface"] + "fileExtensions": ["swift", "swiftinterface"], + "languageServerLaunch": { + "executableNames": ["custom-sourcekit-lsp"], + "arguments": ["--stdio"] + } }, { "id": "perl", @@ -1703,6 +1735,15 @@ mod tests { assert!(swift .file_extensions .contains(&"swiftinterface".to_string())); + let swift_launch = swift + .language_server_launch + .as_ref() + .expect("swift launch descriptor should be overridden"); + assert_eq!( + swift_launch.executable_names, + vec!["custom-sourcekit-lsp".to_string()] + ); + assert_eq!(swift_launch.arguments, vec!["--stdio".to_string()]); assert!(!catalog .providers .iter() diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index 5d4e3816..4d0e0c12 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -39,7 +39,10 @@ let catalogResponse = String(cString: catalogPointer) guard let catalogData = catalogResponse.data(using: .utf8), let catalog = try? JSONSerialization.jsonObject(with: catalogData) as? [String: Any], let providers = catalog["providers"] as? [[String: Any]], - providers.contains(where: { $0["id"] as? String == "swift" }) else { + let swiftProvider = providers.first(where: { $0["id"] as? String == "swift" }), + let swiftLaunch = swiftProvider["languageServerLaunch"] as? [String: Any], + let swiftExecutables = swiftLaunch["executableNames"] as? [String], + swiftExecutables.contains("sourcekit-lsp") else { fputs("Unexpected Rust Core LSP provider catalog: \(catalogResponse)\n", stderr) exit(1) } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f0237b3a..12497c4c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -201,6 +201,12 @@ all matching identifier occurrences. These commands are deliberately text-level fallbacks; precise type-aware behavior belongs to a started language server. +The LSP provider catalog is returned by `lithe_core_lsp_provider_catalog_json`. +Each provider descriptor may include `languageServerLaunch` with ordered +`executableNames` and `arguments`; Swift adapters use this metadata when they +need to start a real language server after the lightweight Rust fallback is not +enough. + `lsp.client*` commands are the transport-independent LSP client core. The platform adapter owns the process/stdin/stdout transport and passes a serialized `state` object through these commands. Responses return From d1001683156db7cf59ff7d78138f0f2ff36e233d Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 00:16:21 +0800 Subject: [PATCH 09/38] Refine LSP control center interactions --- .../Lithe/Views/LSPControlCenterView.swift | 431 +++++++++++------- 1 file changed, 272 insertions(+), 159 deletions(-) diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index bcbd5aa2..f00878e4 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -3,11 +3,13 @@ import SwiftUI struct LSPControlCenterView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings + @State private var selectedProviderID: String? private let metricColumns = [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8) ] + private let serverListHeight: CGFloat = 176 private var copy: LSPControlCenterCopy { LSPControlCenterCopy(language: settings.language) @@ -98,11 +100,22 @@ struct LSPControlCenterView: View { VStack(alignment: .leading, spacing: 7) { sectionTitle(copy.languageServers) - VStack(spacing: 1) { - ForEach(languageServerDescriptors) { descriptor in - serverRow(descriptor) + ScrollView(.vertical) { + if languageServerDescriptors.isEmpty { + Text(copy.noProjectLanguageServers) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: serverListHeight, alignment: .leading) + } else { + VStack(spacing: 1) { + ForEach(languageServerDescriptors) { descriptor in + serverRow(descriptor) + } + } } } + .frame(height: serverListHeight) + .litheScrollViewChrome(hideHorizontal: true) } .padding(10) .panelChrome() @@ -131,21 +144,23 @@ struct LSPControlCenterView: View { Text(copy.title(for: metrics.status)) .font(.system(size: 11, weight: .medium)) .foregroundStyle(statusColor(metrics.status)) - Button { - model.languageToolingSessions.stopLanguageServer(providerID: descriptor.id) - } label: { - Image(systemName: metrics.status == .active ? "stop" : "play") + if metrics.status == .active || metrics.status == .error { + Button { + model.languageToolingSessions.stopLanguageServer(providerID: descriptor.id) + } label: { + Image(systemName: "stop") + } + .litheIconButton() + .help(copy.stopProvider(descriptor.displayName)) } - .litheIconButton() - .help( - metrics.status == .active - ? copy.stopProvider(descriptor.displayName) - : copy.providerStartsOnDemand(descriptor.displayName) - ) } .padding(.horizontal, 7) .frame(height: 38) + .contentShape(Rectangle()) .litheRowHover(isActive: isSelected, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) + .onTapGesture { + selectedProviderID = descriptor.id + } } private func serverDetail(_ descriptor: LanguageProviderDescriptor) -> some View { @@ -173,16 +188,15 @@ struct LSPControlCenterView: View { } HStack(spacing: 0) { - summaryStat(copy.rootFiles, value: "\(metrics.fileCount)") - summaryStat(copy.openFiles, value: "\(metrics.openFileCount)") - summaryStat(copy.diagnostics, value: "\(metrics.diagnosticCount)") - } - - LazyVGrid(columns: metricColumns, spacing: 8) { - metricCard(title: copy.features, value: "\(metrics.featureCount)", color: LitheTheme.accent, progress: metrics.featureProgress) - metricCard(title: copy.indexed, value: metrics.indexProgressText, color: LitheTheme.success, progress: metrics.indexProgress) - metricCard(title: copy.errors, value: "\(metrics.errorCount)", color: LitheTheme.error, progress: metrics.errorProgress) - metricCard(title: copy.warnings, value: "\(metrics.warningCount)", color: LitheTheme.warning, progress: metrics.warningProgress) + summaryButton(copy.rootFiles, value: "\(metrics.fileCount)") { + openFirstProjectFile(for: descriptor) + } + summaryButton(copy.openFiles, value: "\(metrics.openFileCount)") { + openFirstOpenDocument(for: descriptor) + } + summaryButton(copy.diagnostics, value: "\(metrics.diagnosticCount)") { + openFirstDiagnostic(for: descriptor) + } } capabilityGrid(descriptor) @@ -259,39 +273,68 @@ struct LSPControlCenterView: View { } private func capabilityGrid(_ descriptor: LanguageProviderDescriptor) -> some View { - let rows: [(String, String, Bool)] = [ - (copy.definition, "arrowshape.turn.up.right", descriptor.capabilities.contains(.languageServer)), - (copy.completion, "text.cursor", descriptor.capabilities.contains(.languageServer)), - (copy.formatting, "text.alignleft", descriptor.capabilities.contains(.formatting)), - (copy.testing, "checkmark.seal", descriptor.capabilities.contains(.testing)), - (copy.debug, "ladybug", descriptor.capabilities.contains(.debugAdapter)), - (copy.run, "play", descriptor.capabilities.contains(.run)) + let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] ?? [] + let rows: [LSPCapabilityRow] = [ + LSPCapabilityRow( + title: copy.languageServerCapability, + icon: "chevron.left.forwardslash.chevron.right", + declared: descriptor.capabilities.contains(.languageServer), + active: !features.isEmpty || model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) + ), + LSPCapabilityRow( + title: copy.formatting, + icon: "text.alignleft", + declared: descriptor.capabilities.contains(.formatting), + active: features.contains(.formatting) + ), + LSPCapabilityRow( + title: copy.testing, + icon: "checkmark.seal", + declared: descriptor.capabilities.contains(.testing), + active: false + ), + LSPCapabilityRow( + title: copy.debug, + icon: "ladybug", + declared: descriptor.capabilities.contains(.debugAdapter), + active: model.languageToolingSessions.activeDebugAdapterIDs.contains(descriptor.id) + ), + LSPCapabilityRow( + title: copy.run, + icon: "play", + declared: descriptor.capabilities.contains(.run), + active: false + ) ] return VStack(alignment: .leading, spacing: 7) { sectionTitle(copy.capabilities) LazyVGrid(columns: metricColumns, spacing: 6) { - ForEach(rows, id: \.0) { row in - HStack(spacing: 7) { - Image(systemName: row.1) - .frame(width: 15) - Text(row.0) - .lineLimit(1) - Spacer(minLength: 0) - Toggle("", isOn: .constant(row.2)) - .labelsHidden() - .toggleStyle(.switch) - .scaleEffect(0.62) - .allowsHitTesting(false) + ForEach(rows) { row in + Button { + model.showNotification(copy.capabilityState(row.title, declared: row.declared, active: row.active)) + } label: { + HStack(spacing: 7) { + Image(systemName: row.icon) + .frame(width: 15) + Text(row.title) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: row.active ? "bolt.fill" : row.declared ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(row.active ? LitheTheme.success : row.declared ? LitheTheme.accent : LitheTheme.secondaryText) + } + .font(.system(size: 11.5)) + .foregroundStyle(row.declared ? LitheTheme.primaryText : LitheTheme.secondaryText) + .padding(.horizontal, 7) + .frame(height: 30) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(LitheTheme.raised.opacity(0.55)) + ) } - .font(.system(size: 11.5)) - .foregroundStyle(row.2 ? LitheTheme.primaryText : LitheTheme.secondaryText) - .padding(.horizontal, 7) - .frame(height: 30) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(LitheTheme.raised.opacity(0.55)) - ) + .buttonStyle(.plain) + .lithePointer() } } } @@ -311,10 +354,18 @@ struct LSPControlCenterView: View { .lineLimit(1) Spacer(minLength: 0) } - configRow(title: copy.providerID, value: descriptor.id) - configRow(title: copy.activation, value: copy.activationPolicy(descriptor.activationPolicy)) - configRow(title: copy.builtinCatalog, value: "rust/lithe-core/resources/lsp/language-providers.json") - configRow(title: copy.projectOverride, value: projectLSPConfigPath) + configActionRow(title: copy.providerID, value: descriptor.id, systemImage: "doc.on.doc") { + model.showNotification(copy.providerIDCopied(descriptor.id)) + } + configActionRow(title: copy.activation, value: copy.activationPolicy(descriptor.activationPolicy), systemImage: "bolt.badge.clock") { + model.showNotification(copy.activationPolicy(descriptor.activationPolicy)) + } + configActionRow(title: copy.builtinCatalog, value: builtinCatalogPath, systemImage: "curlybraces") { + openFileIfPresent(URL(fileURLWithPath: builtinCatalogPath), missingMessage: copy.builtinCatalogUnavailable) + } + configActionRow(title: copy.projectOverride, value: projectLSPConfigPath, systemImage: "folder.badge.gearshape") { + openFileIfPresent(URL(fileURLWithPath: projectLSPConfigPath), missingMessage: copy.projectOverrideMissing) + } } .padding(8) .background( @@ -329,61 +380,57 @@ struct LSPControlCenterView: View { } } - private func configRow(title: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(title) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 82, alignment: .leading) - Text(value) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) - } - } - - private func metricCard(title: String, value: String, color: Color, progress: Double) -> some View { - VStack(alignment: .leading, spacing: 7) { - Text(title) - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.secondaryText) - Text(value) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - ProgressView(value: min(max(progress, 0), 1)) - .tint(color) - .controlSize(.small) - } - .padding(9) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 7) - .fill(LitheTheme.raised.opacity(0.58)) - ) - .overlay { - RoundedRectangle(cornerRadius: 7) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + private func configActionRow( + title: String, + value: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 82, alignment: .leading) + Image(systemName: systemImage) + .font(.system(size: 10)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 14) + Text(value) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .lithePointer() } - private func summaryStat(_ title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.system(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - Text(value) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 10) - .overlay(alignment: .leading) { - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) + private func summaryButton(_ title: String, value: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 10) + .contentShape(Rectangle()) + .overlay(alignment: .leading) { + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) + } } + .buttonStyle(.plain) + .lithePointer() } private func sectionTitle(_ title: String) -> some View { @@ -426,8 +473,6 @@ struct LSPControlCenterView: View { private func statusColor(_ status: LSPServerStatus) -> Color { switch status { case .active: LitheTheme.success - case .indexing: LitheTheme.warning - case .available: LitheTheme.accent case .stopped: LitheTheme.secondaryText case .error: LitheTheme.error } @@ -436,12 +481,18 @@ struct LSPControlCenterView: View { private var languageServerDescriptors: [LanguageProviderDescriptor] { model.languageProviderCatalog.descriptors .filter { $0.capabilities.contains(.languageServer) } + .filter { descriptor in + model.projectFiles.contains { descriptor.handles(fileURL: $0) } + } } private var selectedDescriptor: LanguageProviderDescriptor? { + if let selectedProviderID, + let selected = languageServerDescriptors.first(where: { $0.id == selectedProviderID }) { + return selected + } if let document = model.activeDocument, - let descriptor = model.languageProviderCatalog.provider(for: document.url), - descriptor.capabilities.contains(.languageServer) { + let descriptor = languageServerDescriptors.first(where: { $0.handles(fileURL: document.url) }) { return descriptor } return languageServerDescriptors.first @@ -451,6 +502,19 @@ struct LSPControlCenterView: View { languageServerDescriptors.filter { providerMetrics(for: $0).status == .active }.count } + private var builtinCatalogPath: String { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("rust") + .appendingPathComponent("lithe-core") + .appendingPathComponent("resources") + .appendingPathComponent("lsp") + .appendingPathComponent("language-providers.json") + .path + } + private var projectLSPConfigPath: String { guard let workspaceURL = model.workspaceURL else { return ".lithe/lsp/language-providers.json" @@ -462,6 +526,26 @@ struct LSPControlCenterView: View { .path } + private func matchingProjectFiles(for descriptor: LanguageProviderDescriptor) -> [URL] { + model.projectFiles.filter { descriptor.handles(fileURL: $0) } + } + + private func matchingOpenDocuments(for descriptor: LanguageProviderDescriptor) -> [EditorDocument] { + model.openDocuments.filter { descriptor.handles(fileURL: $0.url) } + } + + private func matchingDiagnostics(for descriptor: LanguageProviderDescriptor) -> [EditorDiagnostic] { + model.editorDiagnostics + .filter { descriptor.handles(fileURL: $0.key) } + .values + .flatMap { $0 } + .sorted { + if $0.severity != $1.severity { return $0.severity.sortOrder < $1.severity.sortOrder } + if $0.line != $1.line { return $0.line < $1.line } + return $0.message < $1.message + } + } + private var allDiagnostics: [EditorDiagnostic] { model.editorDiagnostics.values .flatMap { $0 } @@ -472,47 +556,66 @@ struct LSPControlCenterView: View { } } + private func openFirstProjectFile(for descriptor: LanguageProviderDescriptor) { + guard let fileURL = matchingProjectFiles(for: descriptor).first else { + model.showNotification(copy.noMatchingFiles) + return + } + model.openFile(fileURL) + } + + private func openFirstOpenDocument(for descriptor: LanguageProviderDescriptor) { + guard let document = matchingOpenDocuments(for: descriptor).first else { + model.showNotification(copy.noOpenFilesForProvider) + return + } + model.openFile(document.url) + } + + private func openFirstDiagnostic(for descriptor: LanguageProviderDescriptor) { + guard let diagnostic = matchingDiagnostics(for: descriptor).first else { + model.showNotification(copy.noLanguageServerDiagnostics) + return + } + model.openDiagnostic(diagnostic) + } + + private func openFileIfPresent(_ url: URL, missingMessage: String) { + if model.workspaceFileOperations.fileExists(at: url) { + model.openFile(url) + } else { + model.showNotification(missingMessage) + } + } + private func providerMetrics(for descriptor: LanguageProviderDescriptor) -> LSPProviderMetrics { - let files = model.projectFiles.filter { descriptor.handles(fileURL: $0) } - let openFiles = model.openDocuments.filter { descriptor.handles(fileURL: $0.url) } - let diagnostics = model.editorDiagnostics - .filter { descriptor.handles(fileURL: $0.key) } - .values - .flatMap { $0 } + let files = matchingProjectFiles(for: descriptor) + let openFiles = matchingOpenDocuments(for: descriptor) + let diagnostics = matchingDiagnostics(for: descriptor) let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] ?? [] let status: LSPServerStatus + let hasServerState = model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) + || !features.isEmpty + || !diagnostics.isEmpty + if diagnostics.contains(where: { $0.severity == .error }) { status = .error } else if model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) || !features.isEmpty { status = .active - } else if !openFiles.isEmpty { - status = .indexing - } else if !files.isEmpty { - status = .available + } else if hasServerState { + status = .active } else { status = .stopped } - let totalDiagnostics = max(diagnostics.count, 1) - let errorCount = diagnostics.filter { $0.severity == .error }.count - let warningCount = diagnostics.filter { $0.severity == .warning }.count - let indexProgress = files.isEmpty ? 0 : min(1, Double(openFiles.count == 0 ? files.count / 2 : files.count) / Double(max(files.count, 1))) - return LSPProviderMetrics( status: status, subtitle: files.isEmpty ? copy.noMatchingFiles : copy.matchingFiles(files.count), workspacePath: model.workspaceURL?.path ?? copy.noWorkspace, - version: copy.providerVersion, + version: copy.title(for: status), fileCount: files.count, openFileCount: openFiles.count, - diagnosticCount: diagnostics.count, - errorCount: errorCount, - warningCount: warningCount, - featureCount: features.enabledFeatureCount, - featureProgress: Double(features.enabledFeatureCount) / 11.0, - indexProgress: indexProgress, - errorProgress: Double(errorCount) / Double(totalDiagnostics), - warningProgress: Double(warningCount) / Double(totalDiagnostics) + diagnosticCount: diagnostics.count ) } @@ -520,12 +623,19 @@ struct LSPControlCenterView: View { private enum LSPServerStatus { case active - case indexing - case available case stopped case error } +private struct LSPCapabilityRow: Identifiable { + let title: String + let icon: String + let declared: Bool + let active: Bool + + var id: String { title } +} + private struct LSPControlCenterCopy { let language: AppLanguage @@ -541,13 +651,12 @@ private struct LSPControlCenterCopy { var restartAll: String { usesChinese ? "全部重启" : "Restart all" } var clearDiagnostics: String { usesChinese ? "清空诊断" : "Clear diagnostics" } var languageServers: String { usesChinese ? "语言服务器" : "Language Servers" } + var noProjectLanguageServers: String { + usesChinese ? "当前项目没有匹配的语言服务器。" : "No matching language servers in this project." + } var rootFiles: String { usesChinese ? "项目文件" : "Root files" } var openFiles: String { usesChinese ? "打开文件" : "Open files" } var diagnostics: String { usesChinese ? "诊断" : "Diagnostics" } - var features: String { usesChinese ? "功能" : "Features" } - var indexed: String { usesChinese ? "索引" : "Indexed" } - var errors: String { usesChinese ? "错误" : "Errors" } - var warnings: String { usesChinese ? "警告" : "Warnings" } var openSupportedFile: String { usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" } var matchingServerWillAppear: String { usesChinese ? "匹配的语言服务器会显示在这里。" : "The matching language server will appear here." @@ -556,6 +665,7 @@ private struct LSPControlCenterCopy { usesChinese ? "暂无语言服务器诊断。" : "No language server diagnostics." } var capabilities: String { usesChinese ? "能力" : "Capabilities" } + var languageServerCapability: String { usesChinese ? "语言服务器" : "Language Server" } var definition: String { usesChinese ? "定义" : "Definition" } var completion: String { usesChinese ? "补全" : "Completion" } var formatting: String { usesChinese ? "格式化" : "Formatting" } @@ -564,7 +674,7 @@ private struct LSPControlCenterCopy { var run: String { usesChinese ? "运行" : "Run" } var noMatchingFiles: String { usesChinese ? "没有匹配文件" : "No matching files" } var noWorkspace: String { usesChinese ? "未打开工作区" : "No workspace" } - var providerVersion: String { usesChinese ? "兼容层" : "provider" } + var notRunning: String { usesChinese ? "未运行" : "Not running" } var providerConfiguration: String { usesChinese ? "Provider 配置" : "Provider Configuration" } var rustOwnedConfiguration: String { usesChinese ? "由 Rust LSP 配置加载" : "Loaded by Rust LSP configuration" @@ -573,6 +683,15 @@ private struct LSPControlCenterCopy { var activation: String { usesChinese ? "启动策略" : "Activation" } var builtinCatalog: String { usesChinese ? "内置 JSON" : "Built-in JSON" } var projectOverride: String { usesChinese ? "项目覆盖" : "Project override" } + var noOpenFilesForProvider: String { + usesChinese ? "这个语言服务器当前没有打开的文件。" : "No open files for this language server." + } + var builtinCatalogUnavailable: String { + usesChinese ? "内置 LSP catalog 文件不可用。" : "The built-in LSP catalog file is unavailable." + } + var projectOverrideMissing: String { + usesChinese ? "当前项目还没有 LSP 覆盖配置。" : "This project has no LSP override configuration yet." + } var configurationHint: String { usesChinese ? "语言、扩展名、能力、命令和平台覆盖只允许写入独立 LSP JSON,由 Rust 兼容层注册。" @@ -597,26 +716,31 @@ private struct LSPControlCenterCopy { usesChinese ? "停止 \(name)" : "Stop \(name)" } - func providerStartsOnDemand(_ name: String) -> String { - usesChinese - ? "\(name) 会在打开匹配文件时启动" - : "\(name) starts when a matching file opens" + func providerIDCopied(_ id: String) -> String { + usesChinese ? "Provider ID:\(id)" : "Provider ID: \(id)" + } + + func capabilityState(_ name: String, declared: Bool, active: Bool) -> String { + if usesChinese { + if active { return "\(name) 当前会话已启用。" } + if declared { return "\(name) 由 catalog 声明,但当前没有运行中的 LSP 会话。" } + return "\(name) 未由 catalog 声明。" + } + if active { return "\(name) is enabled in the current session." } + if declared { return "\(name) is declared by the catalog, but no LSP session is running." } + return "\(name) is not declared by the catalog." } func title(for status: LSPServerStatus) -> String { if usesChinese { switch status { case .active: "运行中" - case .indexing: "索引中" - case .available: "可用" case .stopped: "已停止" case .error: "错误" } } else { switch status { case .active: "Running" - case .indexing: "Indexing" - case .available: "Available" case .stopped: "Stopped" case .error: "Error" } @@ -632,17 +756,6 @@ private struct LSPProviderMetrics { let fileCount: Int let openFileCount: Int let diagnosticCount: Int - let errorCount: Int - let warningCount: Int - let featureCount: Int - let featureProgress: Double - let indexProgress: Double - let errorProgress: Double - let warningProgress: Double - - var indexProgressText: String { - "\(Int((indexProgress * 100).rounded()))%" - } } private extension DiagnosticSeverity { From d2b14d2a997a0641190783a0a78de79c4f0a1daa Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 00:26:57 +0800 Subject: [PATCH 10/38] Wire Swift LSP runtime to Rust client core --- .../Lithe/Core/Ports/LanguageTooling.swift | 54 +++++- Sources/Lithe/Core/RustCoreBridge.swift | 111 +++++++++++ .../LanguageToolingSessionManager.swift | 61 +++++- .../StdioLanguageProviderRuntime.swift | 36 +++- .../Services/StdioLanguageServerSession.swift | 181 ++++++++++++++++++ .../RunConfigurationIntegrationTests.swift | 164 ++++++++++++++++ 6 files changed, 596 insertions(+), 11 deletions(-) create mode 100644 Sources/Lithe/Services/StdioLanguageServerSession.swift diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index da8a49c9..f92c7545 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -332,6 +332,15 @@ extension LanguageTestProvider { } } +@MainActor +protocol LanguageServerSession: AnyObject { + var isRunning: Bool { get } + var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } + func start(rootURL: URL) throws + func synchronize(fileURL: URL, text: String, languageID: String) throws + func stop() +} + @MainActor protocol DebugAdapterSession: AnyObject { var isRunning: Bool { get } @@ -360,7 +369,7 @@ extension DebugAdapterSession { var state: DebugAdapterState { isRunning ? .running : .idle } } -enum ToolingJSONValue: Equatable, Sendable { +enum ToolingJSONValue: Codable, Equatable, Sendable { case string(String) case integer(Int) case number(Double) @@ -398,6 +407,45 @@ enum ToolingJSONValue: Equatable, Sendable { } return nil } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .integer(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([ToolingJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: ToolingJSONValue].self)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .integer(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } } enum DebugAdapterState: String, Equatable, Sendable { @@ -521,15 +569,19 @@ protocol DebugAdapterControllingSession: DebugAdapterSession { @MainActor protocol LanguageProviderRuntime: AnyObject { var descriptor: LanguageProviderDescriptor { get } + var supportsLanguageServerSession: Bool { get } var supportsDebugAdapterSession: Bool { get } var unavailableToolingMessage: String? { get } + func makeLanguageServerSession() -> (any LanguageServerSession)? func makeDebugAdapterSession() -> (any DebugAdapterSession)? func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? } extension LanguageProviderRuntime { + var supportsLanguageServerSession: Bool { false } var supportsDebugAdapterSession: Bool { false } var unavailableToolingMessage: String? { nil } + func makeLanguageServerSession() -> (any LanguageServerSession)? { nil } func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? { makeDebugAdapterSession() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index c7010505..d172c3b8 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -933,6 +933,64 @@ struct RustCoreBridge: Sendable { let method: String } + struct LspClientResponsePayload: Decodable, Sendable { + let state: ToolingJSONValue + let messages: [String] + let events: [LspClientEventPayload] + } + + struct LspClientEventPayload: Decodable, Sendable { + let kind: String + let requestId: String? + let method: String? + let uri: String? + let diagnostics: [LspClientDiagnosticPayload]? + let result: ToolingJSONValue? + let error: String? + } + + struct LspClientDiagnosticPayload: Decodable, Sendable { + let range: LspRangePayload + let severity: Int? + let message: String + let source: String? + let code: String? + + func makeModel() -> LanguageServerDiagnostic { + LanguageServerDiagnostic( + range: range.makeModel(), + severity: severity, + message: message, + source: source, + code: code + ) + } + } + + private struct LspClientInitializeRequest: Encodable { + let state: ToolingJSONValue? + let rootUri: String + let processId: Int? + } + + private struct LspClientOpenDocumentRequest: Encodable { + let state: ToolingJSONValue + let uri: String + let languageId: String + let text: String + } + + private struct LspClientChangeDocumentRequest: Encodable { + let state: ToolingJSONValue + let uri: String + let text: String + } + + private struct LspClientApplyServerMessageRequest: Encodable { + let state: ToolingJSONValue + let message: String + } + private struct MavenDiagnosticsRequest: Encodable { let root: String let output: String @@ -1865,6 +1923,59 @@ struct RustCoreBridge: Sendable { return response?.makeModels() } + func lspClientInitialize(rootURL: URL) -> LspClientResponsePayload? { + execute( + command: "lsp.clientInitialize", + payload: LspClientInitializeRequest( + state: nil, + rootUri: rootURL.standardizedFileURL.absoluteString, + processId: Int(ProcessInfo.processInfo.processIdentifier) + ) + ) + } + + func lspClientOpenDocument( + state: ToolingJSONValue, + fileURL: URL, + languageID: String, + text: String + ) -> LspClientResponsePayload? { + execute( + command: "lsp.clientOpenDocument", + payload: LspClientOpenDocumentRequest( + state: state, + uri: fileURL.standardizedFileURL.absoluteString, + languageId: languageID, + text: text + ) + ) + } + + func lspClientChangeDocument( + state: ToolingJSONValue, + fileURL: URL, + text: String + ) -> LspClientResponsePayload? { + execute( + command: "lsp.clientChangeDocument", + payload: LspClientChangeDocumentRequest( + state: state, + uri: fileURL.standardizedFileURL.absoluteString, + text: text + ) + ) + } + + func lspClientApplyServerMessage( + state: ToolingJSONValue, + message: String + ) -> LspClientResponsePayload? { + execute( + command: "lsp.clientApplyServerMessage", + payload: LspClientApplyServerMessageRequest(state: state, message: message) + ) + } + private func execute( command: String, payload: Payload diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 590937e6..a397fc14 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -37,6 +37,8 @@ final class LanguageToolingSessionManager: ObservableObject { private var catalog: LanguageProviderCatalog private let core: RustCoreBridge private var runtimesByID: [String: any LanguageProviderRuntime] + private var languageServers: [String: any LanguageServerSession] = [:] + private var languageServerRoots: [String: URL] = [:] private var debugAdapters: [String: any DebugAdapterSession] = [:] private var debugAdapterRoots: [String: URL] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] @@ -55,7 +57,7 @@ final class LanguageToolingSessionManager: ObservableObject { self.init(catalog: registry.catalog, runtimes: registry.toolingRuntimes) } - var activeLanguageServerIDs: Set { [] } + var activeLanguageServerIDs: Set { Set(languageServers.keys) } var activeDebugAdapterIDs: Set { Set(debugAdapters.keys) } func updateCatalog(_ catalog: LanguageProviderCatalog) { @@ -63,6 +65,9 @@ final class LanguageToolingSessionManager: ObservableObject { let validProviderIDs = Set(catalog.descriptors.map(\.id)) languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } diagnostics = diagnostics.filter { catalog.provider(for: $0.key) != nil } + for providerID in Array(languageServers.keys) where !validProviderIDs.contains(providerID) { + stopLanguageServer(providerID: providerID) + } } func provider(for fileURL: URL) -> LanguageProviderDescriptor? { @@ -91,14 +96,41 @@ final class LanguageToolingSessionManager: ObservableObject { func synchronizeLanguageServer( for fileURL: URL, - text _: String, - rootURL _: URL + text: String, + rootURL: URL ) throws { - guard catalog.provider(for: fileURL) != nil else { + guard let descriptor = catalog.provider(for: fileURL) else { throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) } - // Rust LSP host will own didOpen/didChange and diagnostics. Until it - // exists, document synchronization is a no-op so the UI remains stable. + guard descriptor.capabilities.contains(.languageServer) else { return } + guard let runtime = runtimesByID[descriptor.id], + runtime.supportsLanguageServerSession else { + return + } + let normalizedRoot = rootURL.standardizedFileURL + let session: any LanguageServerSession + if let active = languageServers[descriptor.id], + active.isRunning, + languageServerRoots[descriptor.id] == normalizedRoot { + session = active + } else { + languageServers[descriptor.id]?.stop() + guard let created = runtime.makeLanguageServerSession() else { + throw LanguageToolingSessionError.toolingUnavailable( + runtime.unavailableToolingMessage ?? descriptor.displayName + ) + } + configureLanguageServerCallbacks(created, providerID: descriptor.id) + try created.start(rootURL: normalizedRoot) + languageServers[descriptor.id] = created + languageServerRoots[descriptor.id] = normalizedRoot + session = created + } + try session.synchronize( + fileURL: fileURL, + text: text, + languageID: descriptor.languageIdentifier(for: fileURL) + ) } func closeDocument(_ fileURL: URL) { @@ -110,12 +142,17 @@ final class LanguageToolingSessionManager: ObservableObject { } func stopLanguageServer(providerID: String) { + languageServers.removeValue(forKey: providerID)?.stop() + languageServerRoots[providerID] = nil languageServerFeatures[providerID] = nil } func stopAllLanguageServers() { + for session in languageServers.values { session.stop() } diagnostics = [:] languageServerFeatures = [:] + languageServers.removeAll() + languageServerRoots.removeAll() } func navigate( @@ -305,9 +342,12 @@ final class LanguageToolingSessionManager: ObservableObject { } func stopAll() { + for session in languageServers.values { session.stop() } for session in debugAdapters.values { session.stop() } diagnostics = [:] languageServerFeatures = [:] + languageServers.removeAll() + languageServerRoots.removeAll() debugAdapters.removeAll() debugAdapterRoots.removeAll() debugStates = [:] @@ -372,6 +412,15 @@ final class LanguageToolingSessionManager: ObservableObject { && core.isAvailable } + private func configureLanguageServerCallbacks( + _ session: any LanguageServerSession, + providerID _: String + ) { + session.onDiagnostics = { [weak self] fileURL, diagnostics in + self?.diagnostics[fileURL.standardizedFileURL] = diagnostics + } + } + private func configureDebugCallbacks( _ session: any DebugAdapterSession, providerID: String diff --git a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift index a6d0c442..c5bdbbc9 100644 --- a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift +++ b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift @@ -5,15 +5,22 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { let descriptor: LanguageProviderDescriptor private let runtimeService: ProjectRuntimeService private let processFactory: () -> any RawProcessSession + private let languageServerLaunch: LanguageServerLaunchDescriptor? + private let languageServerCore: any LspClientCore private let debugLaunch: StdioDebugAdapterLaunch? private let debugSessionFactory: (() -> (any DebugAdapterSession)?)? + var supportsLanguageServerSession: Bool { + languageServerLaunch != nil + } + var supportsDebugAdapterSession: Bool { debugLaunch != nil || debugSessionFactory != nil } var unavailableToolingMessage: String? { - guard let command = debugLaunch?.executableNames.first else { return nil } + guard let command = languageServerLaunch?.executableNames.first + ?? debugLaunch?.executableNames.first else { return nil } return runtimeService.missingToolMessage(command) } @@ -21,16 +28,34 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { descriptor: LanguageProviderDescriptor, runtimeService: ProjectRuntimeService, processFactory: @escaping () -> any RawProcessSession, + languageServerLaunch: LanguageServerLaunchDescriptor? = nil, + languageServerCore: any LspClientCore = RustCoreBridge(), debugLaunch: StdioDebugAdapterLaunch? = nil, debugSessionFactory: (() -> (any DebugAdapterSession)?)? = nil ) { self.descriptor = descriptor self.runtimeService = runtimeService self.processFactory = processFactory + self.languageServerLaunch = languageServerLaunch + self.languageServerCore = languageServerCore self.debugLaunch = debugLaunch self.debugSessionFactory = debugSessionFactory } + func makeLanguageServerSession() -> (any LanguageServerSession)? { + guard let languageServerLaunch else { return nil } + guard let executableURL = languageServerLaunch.executableNames.lazy.compactMap({ + self.runtimeService.executableOnPath($0) + }).first else { return nil } + return StdioLanguageServerSession( + executableURL: executableURL, + arguments: languageServerLaunch.arguments, + environment: runtimeService.processEnvironment(), + process: processFactory(), + core: languageServerCore + ) + } + func makeDebugAdapterSession() -> (any DebugAdapterSession)? { if let debugSessionFactory { return debugSessionFactory() } guard let debugLaunch else { return nil } @@ -59,13 +84,16 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] ) -> [any LanguageProviderRuntime] { packs.compactMap { pack in - guard pack.descriptor.capabilities.contains(.debugAdapter) else { return nil } - guard pack.debugAdapterLaunch != nil || debugSessionFactories[pack.descriptor.id] != nil - else { return nil } + let hasLanguageServer = pack.descriptor.capabilities.contains(.languageServer) + && pack.descriptor.languageServerLaunch != nil + let hasDebugAdapter = pack.descriptor.capabilities.contains(.debugAdapter) + && (pack.debugAdapterLaunch != nil || debugSessionFactories[pack.descriptor.id] != nil) + guard hasLanguageServer || hasDebugAdapter else { return nil } return StdioLanguageProviderRuntime( descriptor: pack.descriptor, runtimeService: runtimeService, processFactory: processFactory, + languageServerLaunch: pack.descriptor.languageServerLaunch, debugLaunch: pack.debugAdapterLaunch, debugSessionFactory: debugSessionFactories[pack.descriptor.id] ) diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift new file mode 100644 index 00000000..7c306cdc --- /dev/null +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -0,0 +1,181 @@ +import Foundation + +protocol LspClientCore: Sendable { + func lspClientInitialize(rootURL: URL) -> RustCoreBridge.LspClientResponsePayload? + func lspClientOpenDocument( + state: ToolingJSONValue, + fileURL: URL, + languageID: String, + text: String + ) -> RustCoreBridge.LspClientResponsePayload? + func lspClientChangeDocument( + state: ToolingJSONValue, + fileURL: URL, + text: String + ) -> RustCoreBridge.LspClientResponsePayload? + func lspClientApplyServerMessage( + state: ToolingJSONValue, + message: String + ) -> RustCoreBridge.LspClientResponsePayload? +} + +extension RustCoreBridge: LspClientCore {} + +@MainActor +final class StdioLanguageServerSession: LanguageServerSession { + private let executableURL: URL + private let arguments: [String] + private let environment: [String: String] + private let process: any RawProcessSession + private let core: any LspClientCore + private var state: ToolingJSONValue? + private var readBuffer = Data() + private var openedDocumentURIs: Set = [] + private var pendingDocuments: [String: PendingDocument] = [:] + private var isInitialized = false + + var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? + + init( + executableURL: URL, + arguments: [String], + environment: [String: String], + process: any RawProcessSession, + core: any LspClientCore = RustCoreBridge() + ) { + self.executableURL = executableURL + self.arguments = arguments + self.environment = environment + self.process = process + self.core = core + process.onOutput = { [weak self] data in + Task { @MainActor [weak self] in self?.receive(data) } + } + process.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in self?.resetTransientState() } + } + } + + var isRunning: Bool { process.isRunning } + + func start(rootURL: URL) throws { + try process.start(ProcessRequest( + operationID: UUID().uuidString, + executablePath: executableURL.path, + arguments: arguments, + workingDirectory: rootURL.standardizedFileURL.path, + environment: environment, + keepsStandardInputOpen: true + )) + guard let response = core.lspClientInitialize(rootURL: rootURL) else { return } + apply(response) + } + + func synchronize(fileURL: URL, text: String, languageID: String) throws { + guard let state else { return } + let standardizedURL = fileURL.standardizedFileURL + let uri = standardizedURL.absoluteString + guard isInitialized else { + pendingDocuments[uri] = PendingDocument( + fileURL: standardizedURL, + text: text, + languageID: languageID + ) + return + } + let response: RustCoreBridge.LspClientResponsePayload? + if openedDocumentURIs.contains(uri) { + response = core.lspClientChangeDocument(state: state, fileURL: standardizedURL, text: text) + } else { + response = core.lspClientOpenDocument( + state: state, + fileURL: standardizedURL, + languageID: languageID, + text: text + ) + openedDocumentURIs.insert(uri) + } + if let response { apply(response) } + } + + func stop() { + process.stop() + resetTransientState() + } + + private func apply(_ response: RustCoreBridge.LspClientResponsePayload) { + state = response.state + response.messages.forEach(sendRawJSON) + for event in response.events { + if event.method == "initialize", event.kind == "response" { + isInitialized = true + flushPendingDocuments() + } + if event.kind == "diagnostics", + let uri = event.uri, + let url = URL(string: uri), + let diagnostics = event.diagnostics { + onDiagnostics?(url.standardizedFileURL, diagnostics.map { $0.makeModel() }) + } + } + } + + private func flushPendingDocuments() { + let documents = pendingDocuments.values + pendingDocuments.removeAll() + for document in documents { + try? synchronize( + fileURL: document.fileURL, + text: document.text, + languageID: document.languageID + ) + } + } + + private func sendRawJSON(_ message: String) { + guard let body = message.data(using: .utf8) else { return } + var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + framed.append(body) + try? process.send(framed) + } + + private func receive(_ data: Data) { + readBuffer.append(data) + while let headerEnd = readBuffer.range(of: Data("\r\n\r\n".utf8)) { + let headerData = readBuffer[..= bodyStart + contentLength else { return } + let body = readBuffer.subdata(in: bodyStart..<(bodyStart + contentLength)) + readBuffer.removeSubrange(0..<(bodyStart + contentLength)) + guard let message = String(data: body, encoding: .utf8), + let state else { continue } + if let response = core.lspClientApplyServerMessage(state: state, message: message) { + apply(response) + } + } + } + + private func resetTransientState() { + state = nil + readBuffer = Data() + openedDocumentURIs = [] + pendingDocuments = [:] + isInitialized = false + } + + private struct PendingDocument { + let fileURL: URL + let text: String + let languageID: String + } +} diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 08461251..0fb98233 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -989,6 +989,86 @@ struct RunConfigurationIntegrationTests { #expect(manager.activeLanguageServerIDs.isEmpty) } + @Test + func languageServerRuntimeStartsFromRustCatalogLaunchMetadata() async throws { + let descriptor = LanguageProviderDescriptor( + id: "swift", + displayName: "Swift", + fileExtensions: ["swift"], + capabilities: [.languageServer, .formatting], + activationPolicy: .onDemand, + languageIdentifier: "swift", + languageServerLaunch: LanguageServerLaunchDescriptor( + executableNames: ["sourcekit-lsp"], + arguments: [] + ) + ) + let runtimeService = ProjectRuntimeService( + runtimeLocator: RunTestRuntimeLocator(), + store: RunTestKeyValueStore() + ) + let process = RecordingRawProcessSession() + let root = URL(fileURLWithPath: "/tmp/swift-project", isDirectory: true) + let source = root.appendingPathComponent("App.swift") + let runtime = StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + processFactory: { process }, + languageServerLaunch: descriptor.languageServerLaunch, + languageServerCore: TestLspClientCore(diagnosticURL: source) + ) + let manager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimes: [runtime] + ) + + try manager.synchronizeLanguageServer( + for: source, + text: "struct App {}\n", + rootURL: root + ) + let startRequest = try #require(process.requests.first) + #expect(startRequest.executablePath == "/usr/bin/sourcekit-lsp") + #expect(startRequest.arguments.isEmpty) + #expect(manager.activeLanguageServerIDs == ["swift"]) + #expect(String(data: try #require(process.sentData.first), encoding: .utf8)?.contains("\"method\":\"initialize\"") == true) + + process.emitJSON([ + "jsonrpc": "2.0", + "id": "1", + "result": [ + "capabilities": [ + "hoverProvider": true, + "completionProvider": [:] + ] + ] + ]) + await Self.drainMainActorTasks() + + let framedOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() + #expect(framedOutput.contains("\"method\":\"initialized\"")) + #expect(framedOutput.contains("\"method\":\"textDocument/didOpen\"")) + + process.emitJSON([ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": [ + "uri": source.standardizedFileURL.absoluteString, + "diagnostics": [[ + "range": [ + "start": ["line": 0, "character": 7], + "end": ["line": 0, "character": 10] + ], + "severity": 2, + "source": "sourcekit-lsp", + "message": "example warning" + ]] + ] + ]) + await Self.drainMainActorTasks() + #expect(manager.diagnostics[source.standardizedFileURL]?.first?.message == "example warning") + } + @Test func stoppingToolingSessionsClearsProjectScopedBreakpoints() throws { let descriptor = try #require(LanguageProviderCatalog.standard.provider( @@ -2626,6 +2706,90 @@ private final class RecordingRunExecutableResolver: RunExecutableResolving { } } +private struct TestLspClientCore: LspClientCore { + let diagnosticURL: URL + + func lspClientInitialize(rootURL _: URL) -> RustCoreBridge.LspClientResponsePayload? { + response( + messages: [#"{"jsonrpc":"2.0","id":"1","method":"initialize","params":{}}"#] + ) + } + + func lspClientOpenDocument( + state _: ToolingJSONValue, + fileURL: URL, + languageID: String, + text: String + ) -> RustCoreBridge.LspClientResponsePayload? { + response(messages: [ + #"{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"\#(fileURL.standardizedFileURL.absoluteString)","languageId":"\#(languageID)","version":1,"text":"\#(text)"}}}"# + ]) + } + + func lspClientChangeDocument( + state _: ToolingJSONValue, + fileURL _: URL, + text _: String + ) -> RustCoreBridge.LspClientResponsePayload? { + response() + } + + func lspClientApplyServerMessage( + state _: ToolingJSONValue, + message: String + ) -> RustCoreBridge.LspClientResponsePayload? { + if message.contains("publishDiagnostics") { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "diagnostics", + requestId: nil, + method: nil, + uri: diagnosticURL.standardizedFileURL.absoluteString, + diagnostics: [ + RustCoreBridge.LspClientDiagnosticPayload( + range: RustCoreBridge.LspRangePayload( + start: RustCoreBridge.LspPositionPayload(line: 0, utf16Column: 7), + end: RustCoreBridge.LspPositionPayload(line: 0, utf16Column: 10) + ), + severity: 2, + message: "example warning", + source: "sourcekit-lsp", + code: nil + ) + ], + result: nil, + error: nil + ) + ]) + } + return response( + messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], + events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "1", + method: "initialize", + uri: nil, + diagnostics: nil, + result: nil, + error: nil + ) + ] + ) + } + + private func response( + messages: [String] = [], + events: [RustCoreBridge.LspClientEventPayload] = [] + ) -> RustCoreBridge.LspClientResponsePayload { + RustCoreBridge.LspClientResponsePayload( + state: .object([:]), + messages: messages, + events: events + ) + } +} + private final class RecordingRawProcessSession: RawProcessSession, @unchecked Sendable { var isRunning = false var onOutput: (@Sendable (Data) -> Void)? From 84622a96f94cc171ae373cc749ea75a0b1dd1da6 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 00:34:13 +0800 Subject: [PATCH 11/38] Route LSP feature requests through Rust core --- .../Lithe/Core/Ports/LanguageTooling.swift | 16 ++ Sources/Lithe/Core/RustCoreBridge.swift | 52 +++- .../LanguageToolingSessionManager.swift | 31 +++ .../Services/StdioLanguageServerSession.swift | 142 ++++++++++- .../RunConfigurationIntegrationTests.swift | 62 +++++ rust/lithe-core/src/lsp.rs | 231 +++++++++++++++++- shared/contracts/rust-core-api.md | 3 + 7 files changed, 526 insertions(+), 11 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index f92c7545..18ca2a89 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -338,6 +338,22 @@ protocol LanguageServerSession: AnyObject { var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } func start(rootURL: URL) throws func synchronize(fileURL: URL, text: String, languageID: String) throws + func completions( + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws + func hover( + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result) -> Void + ) throws + func navigate( + method: String, + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws func stop() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index d172c3b8..a8676d4f 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -421,20 +421,25 @@ struct RustCoreBridge: Sendable { let insertText: String let kind: Int? let detail: String? - let textEdit: LspTextEditPayload + let documentation: String? + let sortText: String? + let filterText: String? + let textEdit: LspTextEditPayload? + let additionalTextEdits: [LspTextEditPayload]? + let data: ToolingJSONValue? func makeModel() -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, detail: detail, - documentation: nil, + documentation: documentation, insertText: insertText, - sortText: nil, - filterText: nil, + sortText: sortText, + filterText: filterText, kind: kind, - textEdit: textEdit.makeModel(), - additionalTextEdits: [], - data: nil + textEdit: textEdit?.makeModel(), + additionalTextEdits: additionalTextEdits?.map { $0.makeModel() } ?? [], + data: data ) } } @@ -450,13 +455,13 @@ struct RustCoreBridge: Sendable { struct Hover: Decodable, Sendable { let contents: String let isMarkdown: Bool - let range: LspRangePayload + let range: LspRangePayload? func makeModel() -> LanguageServerHover { LanguageServerHover( contents: contents, isMarkdown: isMarkdown, - range: range.makeModel() + range: range?.makeModel() ) } } @@ -986,6 +991,14 @@ struct RustCoreBridge: Sendable { let text: String } + private struct LspClientFeatureRequest: Encodable { + let state: ToolingJSONValue + let uri: String + let method: String + let position: LspTextEditsRequest.TextEdit.Range.Position? + let newName: String? + } + private struct LspClientApplyServerMessageRequest: Encodable { let state: ToolingJSONValue let message: String @@ -1966,6 +1979,27 @@ struct RustCoreBridge: Sendable { ) } + func lspClientRequest( + state: ToolingJSONValue, + fileURL: URL, + method: String, + position: LanguageServerPosition? = nil, + newName: String? = nil + ) -> LspClientResponsePayload? { + execute( + command: "lsp.clientRequest", + payload: LspClientFeatureRequest( + state: state, + uri: fileURL.standardizedFileURL.absoluteString, + method: method, + position: position.map { + .init(line: $0.line, utf16Column: $0.utf16Column) + }, + newName: newName + ) + ) + } + func lspClientApplyServerMessage( state: ToolingJSONValue, message: String diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index a397fc14..97f9d61e 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -163,6 +163,18 @@ final class LanguageToolingSessionManager: ObservableObject { rootURL _: URL, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.navigate( + method: method, + fileURL: fileURL, + position: position, + completion: completion + ) + return + } catch {} + } guard supportsBuiltinLanguageServer(for: fileURL) else { throw unavailableLanguageServerError(for: fileURL) } @@ -181,6 +193,13 @@ final class LanguageToolingSessionManager: ObservableObject { rootURL _: URL, completion: @escaping (Result) -> Void ) throws { + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.hover(fileURL: fileURL, position: position, completion: completion) + return + } catch {} + } guard supportsBuiltinLanguageServer(for: fileURL) else { throw unavailableLanguageServerError(for: fileURL) } @@ -198,6 +217,13 @@ final class LanguageToolingSessionManager: ObservableObject { rootURL _: URL, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.completions(fileURL: fileURL, position: position, completion: completion) + return + } catch {} + } guard supportsBuiltinLanguageServer(for: fileURL) else { throw unavailableLanguageServerError(for: fileURL) } @@ -412,6 +438,11 @@ final class LanguageToolingSessionManager: ObservableObject { && core.isAvailable } + private func languageServerSession(for fileURL: URL) -> (any LanguageServerSession)? { + guard let descriptor = catalog.provider(for: fileURL) else { return nil } + return languageServers[descriptor.id] + } + private func configureLanguageServerCallbacks( _ session: any LanguageServerSession, providerID _: String diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 7c306cdc..ec10d3a3 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -13,6 +13,13 @@ protocol LspClientCore: Sendable { fileURL: URL, text: String ) -> RustCoreBridge.LspClientResponsePayload? + func lspClientRequest( + state: ToolingJSONValue, + fileURL: URL, + method: String, + position: LanguageServerPosition?, + newName: String? + ) -> RustCoreBridge.LspClientResponsePayload? func lspClientApplyServerMessage( state: ToolingJSONValue, message: String @@ -32,6 +39,7 @@ final class StdioLanguageServerSession: LanguageServerSession { private var readBuffer = Data() private var openedDocumentURIs: Set = [] private var pendingDocuments: [String: PendingDocument] = [:] + private var responseHandlers: [String: (RustCoreBridge.LspClientEventPayload) -> Void] = [:] private var isInitialized = false var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? @@ -98,6 +106,48 @@ final class StdioLanguageServerSession: LanguageServerSession { if let response { apply(response) } } + func completions( + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + try requestFeature( + method: "textDocument/completion", + fileURL: fileURL, + position: position + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.BuiltinCompletionPayload.self) + .map { $0.makeModels() }) + } + } + + func hover( + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result) -> Void + ) throws { + try requestFeature( + method: "textDocument/hover", + fileURL: fileURL, + position: position + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.BuiltinHoverPayload.self) + .map { $0.hover?.makeModel() }) + } + } + + func navigate( + method: String, + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + try requestFeature(method: method, fileURL: fileURL, position: position) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.BuiltinNavigationPayload.self) + .map { $0.makeModels() }) + } + } + func stop() { process.stop() resetTransientState() @@ -106,7 +156,15 @@ final class StdioLanguageServerSession: LanguageServerSession { private func apply(_ response: RustCoreBridge.LspClientResponsePayload) { state = response.state response.messages.forEach(sendRawJSON) - for event in response.events { + handle(response.events) + } + + private func handle(_ events: [RustCoreBridge.LspClientEventPayload]) { + for event in events { + if let requestID = event.requestId, + let handler = responseHandlers.removeValue(forKey: requestID) { + handler(event) + } if event.method == "initialize", event.kind == "response" { isInitialized = true flushPendingDocuments() @@ -120,6 +178,37 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + private func requestFeature( + method: String, + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void + ) throws { + guard let state, isInitialized else { + throw StdioLanguageServerSessionError.notReady + } + guard openedDocumentURIs.contains(fileURL.standardizedFileURL.absoluteString) else { + throw StdioLanguageServerSessionError.documentNotOpen + } + guard let response = core.lspClientRequest( + state: state, + fileURL: fileURL, + method: method, + position: position, + newName: nil + ) else { + throw StdioLanguageServerSessionError.requestRejected + } + self.state = response.state + for message in response.messages { + if let requestID = Self.requestID(from: message) { + responseHandlers[requestID] = completion + } + sendRawJSON(message) + } + handle(response.events) + } + private func flushPendingDocuments() { let documents = pendingDocuments.values pendingDocuments.removeAll() @@ -170,12 +259,63 @@ final class StdioLanguageServerSession: LanguageServerSession { readBuffer = Data() openedDocumentURIs = [] pendingDocuments = [:] + responseHandlers = [:] isInitialized = false } + private static func requestID(from message: String) -> String? { + guard let data = message.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = object["id"] else { return nil } + if let string = id as? String { return string } + if let number = id as? NSNumber { return number.stringValue } + return nil + } + + private static func decodeEventResult( + _ event: RustCoreBridge.LspClientEventPayload, + as _: Payload.Type + ) -> Result { + if let error = event.error { + return .failure(StdioLanguageServerSessionError.serverError(error)) + } + guard let result = event.result else { + return .failure(StdioLanguageServerSessionError.missingResult) + } + do { + let data = try JSONSerialization.data(withJSONObject: result.foundationObject) + return .success(try JSONDecoder().decode(Payload.self, from: data)) + } catch { + return .failure(error) + } + } + private struct PendingDocument { let fileURL: URL let text: String let languageID: String } + + private enum StdioLanguageServerSessionError: LocalizedError { + case notReady + case documentNotOpen + case requestRejected + case missingResult + case serverError(String) + + var errorDescription: String? { + switch self { + case .notReady: + "Language server is not ready." + case .documentNotOpen: + "Document is not open in the language server." + case .requestRejected: + "Language server request was rejected by Rust core." + case .missingResult: + "Language server response did not include a result." + case .serverError(let message): + message + } + } + } } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 0fb98233..c7ae2761 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1067,6 +1067,32 @@ struct RunConfigurationIntegrationTests { ]) await Self.drainMainActorTasks() #expect(manager.diagnostics[source.standardizedFileURL]?.first?.message == "example warning") + + var completionsResult: Result<[LanguageServerCompletionItem], Error>? + try manager.completions( + fileURL: source, + text: "struct App { let tit }\n", + position: LanguageServerPosition(line: 0, utf16Column: 20), + rootURL: root + ) { result in + completionsResult = result + } + #expect(process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() + .contains("\"method\":\"textDocument/completion\"")) + process.emitJSON([ + "jsonrpc": "2.0", + "id": "2", + "result": [ + "items": [[ + "label": "title", + "insertText": "title", + "kind": 6, + "detail": "String" + ]] + ] + ]) + await Self.drainMainActorTasks() + #expect(try completionsResult?.get().first?.label == "title") } @Test @@ -2734,6 +2760,18 @@ private struct TestLspClientCore: LspClientCore { response() } + func lspClientRequest( + state _: ToolingJSONValue, + fileURL _: URL, + method: String, + position _: LanguageServerPosition?, + newName _: String? + ) -> RustCoreBridge.LspClientResponsePayload? { + response(messages: [ + #"{"jsonrpc":"2.0","id":"2","method":"\#(method)","params":{}}"# + ]) + } + func lspClientApplyServerMessage( state _: ToolingJSONValue, message: String @@ -2762,6 +2800,30 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"2""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "2", + method: "textDocument/completion", + uri: nil, + diagnostics: nil, + result: .object([ + "items": .array([ + .object([ + "label": .string("title"), + "insertText": .string("title"), + "kind": .integer(6), + "detail": .string("String"), + "additionalTextEdits": .array([]), + "data": .null + ]) + ]) + ]), + error: nil + ) + ]) + } return response( messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index 16109dbc..f11e69e3 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -720,6 +720,7 @@ pub fn client_apply_server_message( responses.push(json_rpc_notification("initialized", json!({}))?); } } + let result = lsp_feature_result_for_method(pending.as_deref(), message.get("result")); events.push(LspClientEvent { kind: if message.get("error").is_some() { "error".to_string() @@ -730,7 +731,7 @@ pub fn client_apply_server_message( method: pending, uri: None, diagnostics: None, - result: message.get("result").cloned(), + result, error: message.get("error").map(|value| value.to_string()), }); } @@ -1018,6 +1019,182 @@ fn parse_lsp_position(value: &Value) -> Option { }) } +fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) -> Option { + let result = result?; + match method { + Some("textDocument/completion") => Some(json!({ + "items": parse_completion_items(result) + })), + Some("textDocument/hover") => Some(json!({ + "hover": parse_hover(result) + })), + Some("textDocument/definition") + | Some("textDocument/declaration") + | Some("textDocument/typeDefinition") + | Some("textDocument/implementation") + | Some("textDocument/references") => Some(json!({ + "locations": parse_locations(result) + })), + _ => Some(result.clone()), + } +} + +fn parse_completion_items(result: &Value) -> Vec { + let values = result + .as_array() + .or_else(|| result.get("items").and_then(Value::as_array)); + let Some(values) = values else { + return Vec::new(); + }; + values + .iter() + .filter_map(|item| { + let label = item.get("label").and_then(Value::as_str)?; + let insert_text = item + .get("insertText") + .and_then(Value::as_str) + .or_else(|| { + item.get("textEdit") + .and_then(|edit| edit.get("newText")) + .and_then(Value::as_str) + }) + .unwrap_or(label); + Some(json!({ + "label": label, + "insertText": insert_text, + "kind": item.get("kind").and_then(Value::as_i64), + "detail": item.get("detail").and_then(Value::as_str), + "documentation": completion_documentation(item.get("documentation")), + "sortText": item.get("sortText").and_then(Value::as_str), + "filterText": item.get("filterText").and_then(Value::as_str), + "textEdit": item.get("textEdit").and_then(parse_lsp_text_edit_value), + "additionalTextEdits": item + .get("additionalTextEdits") + .and_then(Value::as_array) + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default(), + "data": item.get("data").cloned().unwrap_or(Value::Null) + })) + }) + .collect() +} + +fn completion_documentation(value: Option<&Value>) -> Option { + match value? { + Value::String(text) => Some(text.clone()), + Value::Object(object) => object + .get("value") + .and_then(Value::as_str) + .map(ToString::to_string), + _ => None, + } +} + +fn parse_hover(result: &Value) -> Option { + if result.is_null() { + return None; + } + let contents = hover_contents(result.get("contents").unwrap_or(result))?; + let range = result.get("range").and_then(parse_lsp_range_value); + Some(json!({ + "contents": contents.0, + "isMarkdown": contents.1, + "range": range + })) +} + +fn hover_contents(value: &Value) -> Option<(String, bool)> { + match value { + Value::String(text) => Some((text.clone(), false)), + Value::Object(object) => { + if let Some(value) = object.get("value").and_then(Value::as_str) { + let is_markdown = object + .get("kind") + .and_then(Value::as_str) + .map(|kind| kind == "markdown") + .unwrap_or(false); + Some((value.to_string(), is_markdown)) + } else if let Some(value) = object.get("language").and_then(Value::as_str) { + Some((value.to_string(), true)) + } else { + None + } + } + Value::Array(values) => { + let parts: Vec<_> = values + .iter() + .filter_map(hover_contents) + .map(|(text, _)| text) + .collect(); + if parts.is_empty() { + None + } else { + Some((parts.join("\n\n"), true)) + } + } + _ => None, + } +} + +fn parse_locations(result: &Value) -> Vec { + let values: Vec<&Value> = if let Some(array) = result.as_array() { + array.iter().collect() + } else if result.is_object() { + vec![result] + } else { + Vec::new() + }; + values + .into_iter() + .filter_map(|location| { + let uri = location + .get("uri") + .or_else(|| location.get("targetUri")) + .and_then(Value::as_str)?; + let range = location + .get("range") + .or_else(|| location.get("targetSelectionRange")) + .or_else(|| location.get("targetRange")) + .and_then(parse_lsp_range_value)?; + Some(json!({ + "filePath": file_path_from_uri(uri), + "range": range, + "isReadOnly": false, + "displayPath": Value::Null + })) + }) + .collect() +} + +fn parse_lsp_text_edit_value(value: &Value) -> Option { + Some(json!({ + "range": parse_lsp_range_value(value.get("range")?)?, + "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() + })) +} + +fn parse_lsp_range_value(value: &Value) -> Option { + Some(json!({ + "start": parse_lsp_position_value(value.get("start")?)?, + "end": parse_lsp_position_value(value.get("end")?)? + })) +} + +fn parse_lsp_position_value(value: &Value) -> Option { + Some(json!({ + "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), + "utf16Column": value + .get("character") + .or_else(|| value.get("utf16Column")) + .and_then(Value::as_i64) + .unwrap_or(0) + })) +} + +fn file_path_from_uri(uri: &str) -> String { + uri.strip_prefix("file://").unwrap_or(uri).to_string() +} + fn feature_names_from_capabilities(capabilities: &Value) -> Vec { let mut values = Vec::new(); add_capability( @@ -2045,6 +2222,58 @@ mod tests { assert_eq!(request_message["params"]["position"]["character"], 12); } + #[test] + fn client_core_shapes_feature_responses_for_swift_models() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() { la }\n".to_string(), + }) + .unwrap(); + let requested = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/completion".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 14, + }), + new_name: None, + }) + .unwrap(); + let completed = client_apply_server_message(ClientApplyServerMessageRequest { + state: requested.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "items": [{ + "label": "launch", + "kind": 3, + "detail": "fn()", + "textEdit": { + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 14 } + }, + "newText": "launch" + } + }] + } + }"# + .to_string(), + }) + .unwrap(); + + let result = completed.events[0].result.as_ref().unwrap(); + assert_eq!(result["items"][0]["label"], "launch"); + assert_eq!( + result["items"][0]["textEdit"]["range"]["start"]["utf16Column"], + 12 + ); + } + #[test] fn client_core_applies_diagnostics_and_dynamic_registrations() { let state = LspClientState::default(); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 12497c4c..4623f756 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -220,6 +220,9 @@ implementation, references, rename, formatting, code action, resolve, and execute-command methods. `lsp.clientApplyServerMessage` parses server responses, derives feature names from initialize capabilities, stores `publishDiagnostics`, and handles dynamic register/unregister notifications. +Completion, hover, and navigation responses are normalized by Rust into the +same completion item, hover, and location payload shapes used by the lightweight +fallback commands. The `history.*` commands accept an adapter-selected `storageRoot`; history metadata never stores an absolute workspace or storage path. `history.record` From cdcbe668c70a7e69ab69b2e73529abf2220b11ba Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:28:06 +0800 Subject: [PATCH 12/38] Wire LSP rename and formatting through Rust core --- .../Lithe/Core/Ports/LanguageTooling.swift | 10 ++ Sources/Lithe/Core/RustCoreBridge.swift | 25 ++++ .../LanguageToolingSessionManager.swift | 27 +++- .../Services/StdioLanguageServerSession.swift | 36 +++++- .../RunConfigurationIntegrationTests.swift | 111 ++++++++++++++++- rust/lithe-core/src/lsp.rs | 117 ++++++++++++++++++ 6 files changed, 318 insertions(+), 8 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 18ca2a89..865cbfe4 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -354,6 +354,16 @@ protocol LanguageServerSession: AnyObject { position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws + func rename( + fileURL: URL, + position: LanguageServerPosition, + newName: String, + completion: @escaping (Result) -> Void + ) throws + func format( + fileURL: URL, + completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void + ) throws func stop() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index a8676d4f..462055a6 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -493,6 +493,31 @@ struct RustCoreBridge: Sendable { } } + struct LspWorkspaceEditPayload: Decodable, Sendable { + let changes: [String: [LspTextEditPayload]] + + func makeModel() -> LanguageServerWorkspaceEdit { + LanguageServerWorkspaceEdit( + changes: Dictionary( + uniqueKeysWithValues: changes.map { path, edits in + ( + URL(fileURLWithPath: path).standardizedFileURL, + edits.map { $0.makeModel() } + ) + } + ) + ) + } + } + + struct LspFormattingPayload: Decodable, Sendable { + let edits: [LspTextEditPayload] + + func makeModels() -> [LanguageServerTextEdit] { + edits.map { $0.makeModel() } + } + } + struct JavaStructurePayload: Decodable, Sendable { struct FoldRegion: Decodable, Sendable { let kind: String diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 97f9d61e..27822f0d 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -237,11 +237,23 @@ final class LanguageToolingSessionManager: ObservableObject { func rename( fileURL: URL, text _: String, - position _: LanguageServerPosition, - newName _: String, + position: LanguageServerPosition, + newName: String, rootURL _: URL, - completion _: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) throws { + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.rename( + fileURL: fileURL, + position: position, + newName: newName, + completion: completion + ) + return + } catch {} + } throw unavailableLanguageServerError(for: fileURL) } @@ -256,8 +268,15 @@ final class LanguageToolingSessionManager: ObservableObject { "insertFinalNewline": true, "trimFinalNewlines": true ], - completion _: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void + completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void ) throws { + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.format(fileURL: fileURL, completion: completion) + return + } catch {} + } throw unavailableLanguageServerError(for: fileURL) } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index ec10d3a3..958a8b14 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -148,6 +148,37 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + func rename( + fileURL: URL, + position: LanguageServerPosition, + newName: String, + completion: @escaping (Result) -> Void + ) throws { + try requestFeature( + method: "textDocument/rename", + fileURL: fileURL, + position: position, + newName: newName + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.LspWorkspaceEditPayload.self) + .map { $0.makeModel() }) + } + } + + func format( + fileURL: URL, + completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void + ) throws { + try requestFeature( + method: "textDocument/formatting", + fileURL: fileURL, + position: nil + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.LspFormattingPayload.self) + .map { $0.makeModels() }) + } + } + func stop() { process.stop() resetTransientState() @@ -181,7 +212,8 @@ final class StdioLanguageServerSession: LanguageServerSession { private func requestFeature( method: String, fileURL: URL, - position: LanguageServerPosition, + position: LanguageServerPosition?, + newName: String? = nil, completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void ) throws { guard let state, isInitialized else { @@ -195,7 +227,7 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: fileURL, method: method, position: position, - newName: nil + newName: newName ) else { throw StdioLanguageServerSessionError.requestRejected } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index c7ae2761..d7b6c13d 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1093,6 +1093,56 @@ struct RunConfigurationIntegrationTests { ]) await Self.drainMainActorTasks() #expect(try completionsResult?.get().first?.label == "title") + + var renameResult: Result? + try manager.rename( + fileURL: source, + text: "struct App { let title = 1 }\n", + position: LanguageServerPosition(line: 0, utf16Column: 17), + newName: "headline", + rootURL: root + ) { result in + renameResult = result + } + process.emitJSON([ + "jsonrpc": "2.0", + "id": "3", + "result": [ + "changes": [ + source.standardizedFileURL.absoluteString: [[ + "range": [ + "start": ["line": 0, "character": 17], + "end": ["line": 0, "character": 22] + ], + "newText": "headline" + ]] + ] + ] + ]) + await Self.drainMainActorTasks() + #expect(try renameResult?.get().changes[source.standardizedFileURL]?.first?.newText == "headline") + + var formatResult: Result<[LanguageServerTextEdit], Error>? + try manager.format( + fileURL: source, + text: "struct App{ }\n", + rootURL: root + ) { result in + formatResult = result + } + process.emitJSON([ + "jsonrpc": "2.0", + "id": "4", + "result": [[ + "range": [ + "start": ["line": 0, "character": 10], + "end": ["line": 0, "character": 10] + ], + "newText": " " + ]] + ]) + await Self.drainMainActorTasks() + #expect(try formatResult?.get().first?.newText == " ") } @Test @@ -2767,8 +2817,17 @@ private struct TestLspClientCore: LspClientCore { position _: LanguageServerPosition?, newName _: String? ) -> RustCoreBridge.LspClientResponsePayload? { - response(messages: [ - #"{"jsonrpc":"2.0","id":"2","method":"\#(method)","params":{}}"# + let id: String + switch method { + case "textDocument/rename": + id = "3" + case "textDocument/formatting": + id = "4" + default: + id = "2" + } + return response(messages: [ + #"{"jsonrpc":"2.0","id":"\#(id)","method":"\#(method)","params":{}}"# ]) } @@ -2824,6 +2883,54 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"3""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "3", + method: "textDocument/rename", + uri: nil, + diagnostics: nil, + result: .object([ + "changes": .object([ + diagnosticURL.standardizedFileURL.path: .array([ + .object([ + "range": .object([ + "start": .object(["line": .integer(0), "utf16Column": .integer(17)]), + "end": .object(["line": .integer(0), "utf16Column": .integer(22)]) + ]), + "newText": .string("headline") + ]) + ]) + ]) + ]), + error: nil + ) + ]) + } + if message.contains(#""id":"4""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "4", + method: "textDocument/formatting", + uri: nil, + diagnostics: nil, + result: .object([ + "edits": .array([ + .object([ + "range": .object([ + "start": .object(["line": .integer(0), "utf16Column": .integer(10)]), + "end": .object(["line": .integer(0), "utf16Column": .integer(10)]) + ]), + "newText": .string(" ") + ]) + ]) + ]), + error: nil + ) + ]) + } return response( messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index f11e69e3..7025efea 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -1028,6 +1028,15 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - Some("textDocument/hover") => Some(json!({ "hover": parse_hover(result) })), + Some("textDocument/rename") => Some(json!({ + "changes": parse_workspace_edit(result) + })), + Some("textDocument/formatting") => Some(json!({ + "edits": result + .as_array() + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default() + })), Some("textDocument/definition") | Some("textDocument/declaration") | Some("textDocument/typeDefinition") @@ -1166,6 +1175,47 @@ fn parse_locations(result: &Value) -> Vec { .collect() } +fn parse_workspace_edit(result: &Value) -> serde_json::Map { + let mut changes = serde_json::Map::new(); + if let Some(entries) = result.get("changes").and_then(Value::as_object) { + for (uri, edits) in entries { + let parsed = edits + .as_array() + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .unwrap_or_default(); + changes.insert(file_path_from_uri(uri), json!(parsed)); + } + } + if let Some(document_changes) = result.get("documentChanges").and_then(Value::as_array) { + for change in document_changes { + let Some(uri) = change + .get("textDocument") + .and_then(|document| document.get("uri")) + .and_then(Value::as_str) + else { + continue; + }; + let parsed = change + .get("edits") + .and_then(Value::as_array) + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .unwrap_or_default(); + changes.insert(file_path_from_uri(uri), json!(parsed)); + } + } + changes +} + fn parse_lsp_text_edit_value(value: &Value) -> Option { Some(json!({ "range": parse_lsp_range_value(value.get("range")?)?, @@ -2272,6 +2322,73 @@ mod tests { result["items"][0]["textEdit"]["range"]["start"]["utf16Column"], 12 ); + + let rename = client_feature_request(ClientFeatureRequest { + state: completed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/rename".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 12, + }), + new_name: Some("start".to_string()), + }) + .unwrap(); + let renamed = client_apply_server_message(ClientApplyServerMessageRequest { + state: rename.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "2", + "result": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + } + }"# + .to_string(), + }) + .unwrap(); + let rename_result = renamed.events[0].result.as_ref().unwrap(); + assert_eq!( + rename_result["changes"]["/tmp/project/main.rs"][0]["newText"], + "start" + ); + + let formatting = client_feature_request(ClientFeatureRequest { + state: renamed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/formatting".to_string(), + position: None, + new_name: None, + }) + .unwrap(); + let formatted = client_apply_server_message(ClientApplyServerMessageRequest { + state: formatting.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "3", + "result": [{ + "range": { + "start": { "line": 0, "character": 2 }, + "end": { "line": 0, "character": 2 } + }, + "newText": " " + }] + }"# + .to_string(), + }) + .unwrap(); + let format_result = formatted.events[0].result.as_ref().unwrap(); + assert_eq!( + format_result["edits"][0]["range"]["start"]["utf16Column"], + 2 + ); } #[test] From 1a6b805dd69409046b84f3d17e7bc95bce7b0dd8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:29:26 +0800 Subject: [PATCH 13/38] Show LSP server list scrollbar --- .../Components/LitheScrollViewChrome.swift | 23 ++++++++++++++----- .../Lithe/Views/LSPControlCenterView.swift | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift index b3e37776..a6828e2a 100644 --- a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift +++ b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift @@ -8,21 +8,25 @@ import SwiftUI /// no visible content of its own. struct LitheScrollViewChrome: NSViewRepresentable { var hideHorizontal = false + var alwaysShowVertical = false func makeNSView(context: Context) -> ScrollViewProbe { - ScrollViewProbe(hideHorizontal: hideHorizontal) + ScrollViewProbe(hideHorizontal: hideHorizontal, alwaysShowVertical: alwaysShowVertical) } func updateNSView(_ nsView: ScrollViewProbe, context: Context) { nsView.hideHorizontal = hideHorizontal + nsView.alwaysShowVertical = alwaysShowVertical nsView.configureEnclosingScrollView() } final class ScrollViewProbe: NSView { var hideHorizontal: Bool + var alwaysShowVertical: Bool - init(hideHorizontal: Bool) { + init(hideHorizontal: Bool, alwaysShowVertical: Bool) { self.hideHorizontal = hideHorizontal + self.alwaysShowVertical = alwaysShowVertical super.init(frame: .zero) } @@ -44,8 +48,9 @@ struct LitheScrollViewChrome: NSViewRepresentable { func configureEnclosingScrollView() { guard let scrollView = enclosingScrollView else { return } - scrollView.scrollerStyle = .overlay - scrollView.autohidesScrollers = true + scrollView.scrollerStyle = alwaysShowVertical ? .legacy : .overlay + scrollView.autohidesScrollers = !alwaysShowVertical + scrollView.hasVerticalScroller = true scrollView.verticalScroller?.knobStyle = .dark scrollView.horizontalScroller?.knobStyle = .dark @@ -58,7 +63,13 @@ struct LitheScrollViewChrome: NSViewRepresentable { } extension View { - func litheScrollViewChrome(hideHorizontal: Bool = false) -> some View { - background(LitheScrollViewChrome(hideHorizontal: hideHorizontal)) + func litheScrollViewChrome( + hideHorizontal: Bool = false, + alwaysShowVertical: Bool = false + ) -> some View { + background(LitheScrollViewChrome( + hideHorizontal: hideHorizontal, + alwaysShowVertical: alwaysShowVertical + )) } } diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index f00878e4..56a9d442 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -115,7 +115,7 @@ struct LSPControlCenterView: View { } } .frame(height: serverListHeight) - .litheScrollViewChrome(hideHorizontal: true) + .litheScrollViewChrome(hideHorizontal: true, alwaysShowVertical: true) } .padding(10) .panelChrome() From 5750b24d7534c7d9f8cbc4f10980b7c2437a6018 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:34:28 +0800 Subject: [PATCH 14/38] Route LSP code actions through Rust core --- .../Lithe/Core/Ports/LanguageTooling.swift | 6 + Sources/Lithe/Core/RustCoreBridge.swift | 76 ++++++- .../LanguageToolingSessionManager.swift | 18 +- .../Services/StdioLanguageServerSession.swift | 28 ++- .../RunConfigurationIntegrationTests.swift | 79 +++++++- rust/lithe-core/src/lsp.rs | 186 ++++++++++++++++++ 6 files changed, 385 insertions(+), 8 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 865cbfe4..2e6d63ef 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -364,6 +364,12 @@ protocol LanguageServerSession: AnyObject { fileURL: URL, completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void ) throws + func codeActions( + fileURL: URL, + range: LanguageServerRange, + diagnostics: [LanguageServerDiagnostic], + completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void + ) throws func stop() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 462055a6..3dd8a78f 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -518,6 +518,48 @@ struct RustCoreBridge: Sendable { } } + struct LspCommandPayload: Decodable, Sendable { + let title: String + let command: String + let arguments: [ToolingJSONValue]? + + func makeModel() -> LanguageServerCommand { + LanguageServerCommand( + title: title, + command: command, + arguments: arguments ?? [] + ) + } + } + + struct LspCodeActionsPayload: Decodable, Sendable { + struct Action: Decodable, Sendable { + let title: String + let kind: String? + let isPreferred: Bool + let edit: LspWorkspaceEditPayload? + let command: LspCommandPayload? + let data: ToolingJSONValue? + + func makeModel() -> LanguageServerCodeAction { + LanguageServerCodeAction( + title: title, + kind: kind, + isPreferred: isPreferred, + edit: edit?.makeModel(), + command: command?.makeModel(), + data: data + ) + } + } + + let actions: [Action] + + func makeModels() -> [LanguageServerCodeAction] { + actions.map { $0.makeModel() } + } + } + struct JavaStructurePayload: Decodable, Sendable { struct FoldRegion: Decodable, Sendable { let kind: String @@ -1022,6 +1064,16 @@ struct RustCoreBridge: Sendable { let method: String let position: LspTextEditsRequest.TextEdit.Range.Position? let newName: String? + let range: LspTextEditsRequest.TextEdit.Range? + let diagnostics: [LspClientDiagnosticRequest] + } + + private struct LspClientDiagnosticRequest: Encodable { + let range: LspTextEditsRequest.TextEdit.Range + let severity: Int? + let message: String + let source: String? + let code: String? } private struct LspClientApplyServerMessageRequest: Encodable { @@ -2009,7 +2061,9 @@ struct RustCoreBridge: Sendable { fileURL: URL, method: String, position: LanguageServerPosition? = nil, - newName: String? = nil + newName: String? = nil, + range: LanguageServerRange? = nil, + diagnostics: [LanguageServerDiagnostic] = [] ) -> LspClientResponsePayload? { execute( command: "lsp.clientRequest", @@ -2020,7 +2074,25 @@ struct RustCoreBridge: Sendable { position: position.map { .init(line: $0.line, utf16Column: $0.utf16Column) }, - newName: newName + newName: newName, + range: range.map { + .init( + start: .init(line: $0.start.line, utf16Column: $0.start.utf16Column), + end: .init(line: $0.end.line, utf16Column: $0.end.utf16Column) + ) + }, + diagnostics: diagnostics.map { + LspClientDiagnosticRequest( + range: .init( + start: .init(line: $0.range.start.line, utf16Column: $0.range.start.utf16Column), + end: .init(line: $0.range.end.line, utf16Column: $0.range.end.utf16Column) + ), + severity: $0.severity, + message: $0.message, + source: $0.source, + code: $0.code + ) + } ) ) } diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 27822f0d..8ebd53e4 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -283,11 +283,23 @@ final class LanguageToolingSessionManager: ObservableObject { func codeActions( fileURL: URL, text _: String, - range _: LanguageServerRange, - diagnostics _: [LanguageServerDiagnostic], + range: LanguageServerRange, + diagnostics: [LanguageServerDiagnostic], rootURL _: URL, - completion _: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void + completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void ) throws { + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.codeActions( + fileURL: fileURL, + range: range, + diagnostics: diagnostics, + completion: completion + ) + return + } catch {} + } throw unavailableLanguageServerError(for: fileURL) } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 958a8b14..14c314eb 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -18,7 +18,9 @@ protocol LspClientCore: Sendable { fileURL: URL, method: String, position: LanguageServerPosition?, - newName: String? + newName: String?, + range: LanguageServerRange?, + diagnostics: [LanguageServerDiagnostic] ) -> RustCoreBridge.LspClientResponsePayload? func lspClientApplyServerMessage( state: ToolingJSONValue, @@ -179,6 +181,24 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + func codeActions( + fileURL: URL, + range: LanguageServerRange, + diagnostics: [LanguageServerDiagnostic], + completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void + ) throws { + try requestFeature( + method: "textDocument/codeAction", + fileURL: fileURL, + position: nil, + range: range, + diagnostics: diagnostics + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.LspCodeActionsPayload.self) + .map { $0.makeModels() }) + } + } + func stop() { process.stop() resetTransientState() @@ -214,6 +234,8 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: URL, position: LanguageServerPosition?, newName: String? = nil, + range: LanguageServerRange? = nil, + diagnostics: [LanguageServerDiagnostic] = [], completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void ) throws { guard let state, isInitialized else { @@ -227,7 +249,9 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: fileURL, method: method, position: position, - newName: newName + newName: newName, + range: range, + diagnostics: diagnostics ) else { throw StdioLanguageServerSessionError.requestRejected } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index d7b6c13d..cd67d7aa 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1143,6 +1143,50 @@ struct RunConfigurationIntegrationTests { ]) await Self.drainMainActorTasks() #expect(try formatResult?.get().first?.newText == " ") + + var actionsResult: Result<[LanguageServerCodeAction], Error>? + try manager.codeActions( + fileURL: source, + text: "struct App{ }\n", + range: LanguageServerRange( + start: LanguageServerPosition(line: 0, utf16Column: 0), + end: LanguageServerPosition(line: 0, utf16Column: 0) + ), + diagnostics: [ + LanguageServerDiagnostic( + range: LanguageServerRange( + start: LanguageServerPosition(line: 0, utf16Column: 7), + end: LanguageServerPosition(line: 0, utf16Column: 10) + ), + severity: 2, + message: "example warning", + source: "sourcekit-lsp", + code: nil + ) + ], + rootURL: root + ) { result in + actionsResult = result + } + process.emitJSON([ + "jsonrpc": "2.0", + "id": "5", + "result": [[ + "title": "Fix warning", + "kind": "quickfix", + "isPreferred": true, + "command": [ + "title": "Apply fix", + "command": "source.fix", + "arguments": [["uri": source.standardizedFileURL.absoluteString]] + ], + "data": ["token": "fix-1"] + ]] + ]) + await Self.drainMainActorTasks() + let actions = try actionsResult?.get() + #expect(actions?.first?.title == "Fix warning") + #expect(actions?.first?.command?.command == "source.fix") } @Test @@ -2815,7 +2859,9 @@ private struct TestLspClientCore: LspClientCore { fileURL _: URL, method: String, position _: LanguageServerPosition?, - newName _: String? + newName _: String?, + range _: LanguageServerRange?, + diagnostics _: [LanguageServerDiagnostic] ) -> RustCoreBridge.LspClientResponsePayload? { let id: String switch method { @@ -2823,6 +2869,8 @@ private struct TestLspClientCore: LspClientCore { id = "3" case "textDocument/formatting": id = "4" + case "textDocument/codeAction": + id = "5" default: id = "2" } @@ -2931,6 +2979,35 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"5""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "5", + method: "textDocument/codeAction", + uri: nil, + diagnostics: nil, + result: .object([ + "actions": .array([ + .object([ + "title": .string("Fix warning"), + "kind": .string("quickfix"), + "isPreferred": .bool(true), + "command": .object([ + "title": .string("Apply fix"), + "command": .string("source.fix"), + "arguments": .array([ + .object(["uri": .string(diagnosticURL.standardizedFileURL.absoluteString)]) + ]) + ]), + "data": .object(["token": .string("fix-1")]) + ]) + ]) + ]), + error: nil + ) + ]) + } return response( messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index 7025efea..f9c11014 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -328,6 +328,10 @@ pub struct ClientFeatureRequest { pub position: Option, #[serde(default)] pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -946,6 +950,17 @@ fn feature_request_params(request: &ClientFeatureRequest) -> Result Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?), + "context": { + "diagnostics": request + .diagnostics + .iter() + .map(lsp_diagnostic_json) + .collect::>() + } + })), _ => Ok(json!({ "textDocument": text_document })), } } @@ -959,6 +974,15 @@ fn required_position(request: &ClientFeatureRequest) -> Result Result { + request.range.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a text document range.", + ) + }) +} + fn lsp_position_json(position: LspPosition) -> Value { json!({ "line": position.line, @@ -966,6 +990,32 @@ fn lsp_position_json(position: LspPosition) -> Value { }) } +fn lsp_range_json(range: LspRange) -> Value { + json!({ + "start": lsp_position_json(range.start), + "end": lsp_position_json(range.end) + }) +} + +fn lsp_diagnostic_json(diagnostic: &LspClientDiagnostic) -> Value { + json!({ + "range": { + "start": { + "line": diagnostic.range.start.line, + "character": diagnostic.range.start.utf16_column + }, + "end": { + "line": diagnostic.range.end.line, + "character": diagnostic.range.end.utf16_column + } + }, + "severity": diagnostic.severity, + "message": diagnostic.message, + "source": diagnostic.source, + "code": diagnostic.code + }) +} + fn lsp_message_id(message: &Value) -> Option { message.get("id").and_then(|id| match id { Value::String(value) => Some(value.clone()), @@ -1037,6 +1087,9 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) .unwrap_or_default() })), + Some("textDocument/codeAction") => Some(json!({ + "actions": parse_code_actions(result) + })), Some("textDocument/definition") | Some("textDocument/declaration") | Some("textDocument/typeDefinition") @@ -1175,6 +1228,48 @@ fn parse_locations(result: &Value) -> Vec { .collect() } +fn parse_code_actions(result: &Value) -> Vec { + let Some(values) = result.as_array() else { + return Vec::new(); + }; + values + .iter() + .filter_map(|action| { + let title = action.get("title").and_then(Value::as_str)?; + let command = if action.get("command").and_then(Value::as_str).is_some() { + parse_lsp_command(action) + } else { + action.get("command").and_then(parse_lsp_command) + }; + Some(json!({ + "title": title, + "kind": action.get("kind").and_then(Value::as_str), + "isPreferred": action + .get("isPreferred") + .and_then(Value::as_bool) + .unwrap_or(false), + "edit": action.get("edit").map(|edit| json!({ + "changes": parse_workspace_edit(edit) + })), + "command": command, + "data": action.get("data").cloned().unwrap_or(Value::Null) + })) + }) + .collect() +} + +fn parse_lsp_command(value: &Value) -> Option { + Some(json!({ + "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), + "command": value.get("command").and_then(Value::as_str)?, + "arguments": value + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + })) +} + fn parse_workspace_edit(result: &Value) -> serde_json::Map { let mut changes = serde_json::Map::new(); if let Some(entries) = result.get("changes").and_then(Value::as_object) { @@ -2261,6 +2356,8 @@ mod tests { utf16_column: 12, }), new_name: None, + range: None, + diagnostics: Vec::new(), }) .unwrap(); assert_eq!( @@ -2290,6 +2387,8 @@ mod tests { utf16_column: 14, }), new_name: None, + range: None, + diagnostics: Vec::new(), }) .unwrap(); let completed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2332,6 +2431,8 @@ mod tests { utf16_column: 12, }), new_name: Some("start".to_string()), + range: None, + diagnostics: Vec::new(), }) .unwrap(); let renamed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2366,6 +2467,8 @@ mod tests { method: "textDocument/formatting".to_string(), position: None, new_name: None, + range: None, + diagnostics: Vec::new(), }) .unwrap(); let formatted = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2389,6 +2492,89 @@ mod tests { format_result["edits"][0]["range"]["start"]["utf16Column"], 2 ); + + let code_actions = client_feature_request(ClientFeatureRequest { + state: formatted.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/codeAction".to_string(), + position: None, + new_name: None, + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 0, + }, + }), + diagnostics: vec![LspClientDiagnostic { + range: LspRangeResponse { + start: LspPositionResponse { + line: 0, + utf16_column: 12, + }, + end: LspPositionResponse { + line: 0, + utf16_column: 18, + }, + }, + severity: Some(2), + message: "rename suggestion".to_string(), + source: Some("rust-analyzer".to_string()), + code: None, + }], + }) + .unwrap(); + let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); + assert_eq!(code_action_request["method"], "textDocument/codeAction"); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["range"]["start"] + ["character"], + 12 + ); + let code_actioned = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_actions.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "4", + "result": [{ + "title": "Apply rename", + "kind": "quickfix", + "isPreferred": true, + "edit": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + }, + "command": { + "title": "Apply", + "command": "rust-analyzer.applySourceChange", + "arguments": [{ "label": "rename" }] + }, + "data": { "id": "action-1" } + }] + }"# + .to_string(), + }) + .unwrap(); + let action_result = code_actioned.events[0].result.as_ref().unwrap(); + assert_eq!(action_result["actions"][0]["title"], "Apply rename"); + assert_eq!( + action_result["actions"][0]["edit"]["changes"]["/tmp/project/main.rs"][0]["newText"], + "start" + ); + assert_eq!( + action_result["actions"][0]["command"]["command"], + "rust-analyzer.applySourceChange" + ); } #[test] From 47b3e3a06054739a150d01032a324118c1678064 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:38:53 +0800 Subject: [PATCH 15/38] Route LSP completion resolve through Rust core --- .../Lithe/Core/Ports/LanguageTooling.swift | 5 + Sources/Lithe/Core/RustCoreBridge.swift | 62 +++++- .../LanguageToolingSessionManager.swift | 9 +- .../Services/StdioLanguageServerSession.swift | 23 ++- .../RunConfigurationIntegrationTests.swift | 62 +++++- rust/lithe-core/src/lsp.rs | 194 +++++++++++++++--- 6 files changed, 322 insertions(+), 33 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 2e6d63ef..c958248e 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -370,6 +370,11 @@ protocol LanguageServerSession: AnyObject { diagnostics: [LanguageServerDiagnostic], completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void ) throws + func resolveCompletion( + _ item: LanguageServerCompletionItem, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws func stop() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 3dd8a78f..730ca7b1 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -451,6 +451,14 @@ struct RustCoreBridge: Sendable { } } + struct LspCompletionResolvePayload: Decodable, Sendable { + let item: BuiltinCompletionPayload.Item + + func makeModel() -> LanguageServerCompletionItem { + item.makeModel() + } + } + struct BuiltinHoverPayload: Decodable, Sendable { struct Hover: Decodable, Sendable { let contents: String @@ -1066,6 +1074,7 @@ struct RustCoreBridge: Sendable { let newName: String? let range: LspTextEditsRequest.TextEdit.Range? let diagnostics: [LspClientDiagnosticRequest] + let completionItem: LspClientCompletionItemRequest? } private struct LspClientDiagnosticRequest: Encodable { @@ -1076,6 +1085,24 @@ struct RustCoreBridge: Sendable { let code: String? } + private struct LspClientCompletionItemRequest: Encodable { + let label: String + let detail: String? + let documentation: String? + let insertText: String + let sortText: String? + let filterText: String? + let kind: Int? + let textEdit: LspClientTextEditRequest? + let additionalTextEdits: [LspClientTextEditRequest] + let data: ToolingJSONValue? + } + + private struct LspClientTextEditRequest: Encodable { + let range: LspTextEditsRequest.TextEdit.Range + let newText: String + } + private struct LspClientApplyServerMessageRequest: Encodable { let state: ToolingJSONValue let message: String @@ -2063,7 +2090,8 @@ struct RustCoreBridge: Sendable { position: LanguageServerPosition? = nil, newName: String? = nil, range: LanguageServerRange? = nil, - diagnostics: [LanguageServerDiagnostic] = [] + diagnostics: [LanguageServerDiagnostic] = [], + completionItem: LanguageServerCompletionItem? = nil ) -> LspClientResponsePayload? { execute( command: "lsp.clientRequest", @@ -2092,11 +2120,41 @@ struct RustCoreBridge: Sendable { source: $0.source, code: $0.code ) - } + }, + completionItem: completionItem.map(Self.makeCompletionItemRequest) ) ) } + private static func makeCompletionItemRequest( + _ item: LanguageServerCompletionItem + ) -> LspClientCompletionItemRequest { + LspClientCompletionItemRequest( + label: item.label, + detail: item.detail, + documentation: item.documentation, + insertText: item.insertText, + sortText: item.sortText, + filterText: item.filterText, + kind: item.kind, + textEdit: item.textEdit.map(makeTextEditRequest), + additionalTextEdits: item.additionalTextEdits.map(makeTextEditRequest), + data: item.data + ) + } + + private static func makeTextEditRequest( + _ edit: LanguageServerTextEdit + ) -> LspClientTextEditRequest { + LspClientTextEditRequest( + range: .init( + start: .init(line: edit.range.start.line, utf16Column: edit.range.start.utf16Column), + end: .init(line: edit.range.end.line, utf16Column: edit.range.end.utf16Column) + ), + newText: edit.newText + ) + } + func lspClientApplyServerMessage( state: ToolingJSONValue, message: String diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 8ebd53e4..6f97b329 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -324,7 +324,7 @@ final class LanguageToolingSessionManager: ObservableObject { fileURL: URL, text _: String, rootURL _: URL, - completion _: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) throws { guard item.label.isEmpty == false else { throw LanguageToolingSessionError.capabilityUnavailable( @@ -332,6 +332,13 @@ final class LanguageToolingSessionManager: ObservableObject { capability: "completion item resolve" ) } + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.resolveCompletion(item, fileURL: fileURL, completion: completion) + return + } catch {} + } throw unavailableLanguageServerError(for: fileURL) } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 14c314eb..3e71a06a 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -20,7 +20,8 @@ protocol LspClientCore: Sendable { position: LanguageServerPosition?, newName: String?, range: LanguageServerRange?, - diagnostics: [LanguageServerDiagnostic] + diagnostics: [LanguageServerDiagnostic], + completionItem: LanguageServerCompletionItem? ) -> RustCoreBridge.LspClientResponsePayload? func lspClientApplyServerMessage( state: ToolingJSONValue, @@ -199,6 +200,22 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + func resolveCompletion( + _ item: LanguageServerCompletionItem, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws { + try requestFeature( + method: "completionItem/resolve", + fileURL: fileURL, + position: nil, + completionItem: item + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.LspCompletionResolvePayload.self) + .map { $0.makeModel() }) + } + } + func stop() { process.stop() resetTransientState() @@ -236,6 +253,7 @@ final class StdioLanguageServerSession: LanguageServerSession { newName: String? = nil, range: LanguageServerRange? = nil, diagnostics: [LanguageServerDiagnostic] = [], + completionItem: LanguageServerCompletionItem? = nil, completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void ) throws { guard let state, isInitialized else { @@ -251,7 +269,8 @@ final class StdioLanguageServerSession: LanguageServerSession { position: position, newName: newName, range: range, - diagnostics: diagnostics + diagnostics: diagnostics, + completionItem: completionItem ) else { throw StdioLanguageServerSessionError.requestRejected } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index cd67d7aa..be898594 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1094,6 +1094,40 @@ struct RunConfigurationIntegrationTests { await Self.drainMainActorTasks() #expect(try completionsResult?.get().first?.label == "title") + var completionResolveResult: Result? + try manager.resolveCompletion( + LanguageServerCompletionItem( + label: "title", + detail: nil, + documentation: nil, + insertText: "title", + sortText: nil, + filterText: nil, + kind: 6, + textEdit: nil, + additionalTextEdits: [], + data: .object(["id": .string("completion-1")]) + ), + fileURL: source, + text: "struct App { let ti = 1 }\n", + rootURL: root + ) { result in + completionResolveResult = result + } + process.emitJSON([ + "jsonrpc": "2.0", + "id": "6", + "result": [ + "label": "title", + "insertText": "title", + "kind": 6, + "detail": "String", + "documentation": "Resolved docs" + ] + ]) + await Self.drainMainActorTasks() + #expect(try completionResolveResult?.get().documentation == "Resolved docs") + var renameResult: Result? try manager.rename( fileURL: source, @@ -2861,7 +2895,8 @@ private struct TestLspClientCore: LspClientCore { position _: LanguageServerPosition?, newName _: String?, range _: LanguageServerRange?, - diagnostics _: [LanguageServerDiagnostic] + diagnostics _: [LanguageServerDiagnostic], + completionItem _: LanguageServerCompletionItem? ) -> RustCoreBridge.LspClientResponsePayload? { let id: String switch method { @@ -2871,6 +2906,8 @@ private struct TestLspClientCore: LspClientCore { id = "4" case "textDocument/codeAction": id = "5" + case "completionItem/resolve": + id = "6" default: id = "2" } @@ -3008,6 +3045,29 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"6""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "6", + method: "completionItem/resolve", + uri: nil, + diagnostics: nil, + result: .object([ + "item": .object([ + "label": .string("title"), + "insertText": .string("title"), + "kind": .integer(6), + "detail": .string("String"), + "documentation": .string("Resolved docs"), + "additionalTextEdits": .array([]), + "data": .object(["id": .string("completion-1")]) + ]) + ]), + error: nil + ) + ]) + } return response( messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index f9c11014..ffe48082 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -332,6 +332,8 @@ pub struct ClientFeatureRequest { pub range: Option, #[serde(default)] pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, } #[derive(Debug, Clone, Deserialize)] @@ -961,6 +963,16 @@ fn feature_request_params(request: &ClientFeatureRequest) -> Result>() } })), + "completionItem/resolve" => request + .completion_item + .as_ref() + .map(swift_completion_item_to_lsp) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a completion item.", + ) + }), _ => Ok(json!({ "textDocument": text_document })), } } @@ -1016,6 +1028,66 @@ fn lsp_diagnostic_json(diagnostic: &LspClientDiagnostic) -> Value { }) } +fn swift_completion_item_to_lsp(item: &Value) -> Value { + let mut object = serde_json::Map::new(); + copy_string_field(item, &mut object, "label"); + copy_string_field(item, &mut object, "detail"); + copy_string_field(item, &mut object, "documentation"); + copy_string_field(item, &mut object, "insertText"); + copy_string_field(item, &mut object, "sortText"); + copy_string_field(item, &mut object, "filterText"); + if let Some(kind) = item.get("kind").and_then(Value::as_i64) { + object.insert("kind".to_string(), json!(kind)); + } + if let Some(edit) = item.get("textEdit").and_then(swift_text_edit_to_lsp) { + object.insert("textEdit".to_string(), edit); + } + if let Some(edits) = item.get("additionalTextEdits").and_then(Value::as_array) { + object.insert( + "additionalTextEdits".to_string(), + json!(edits + .iter() + .filter_map(swift_text_edit_to_lsp) + .collect::>()), + ); + } + if let Some(data) = item.get("data") { + object.insert("data".to_string(), data.clone()); + } + Value::Object(object) +} + +fn copy_string_field(source: &Value, target: &mut serde_json::Map, field: &str) { + if let Some(value) = source.get(field).and_then(Value::as_str) { + target.insert(field.to_string(), json!(value)); + } +} + +fn swift_text_edit_to_lsp(value: &Value) -> Option { + Some(json!({ + "range": swift_range_to_lsp(value.get("range")?)?, + "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() + })) +} + +fn swift_range_to_lsp(value: &Value) -> Option { + Some(json!({ + "start": swift_position_to_lsp(value.get("start")?)?, + "end": swift_position_to_lsp(value.get("end")?)? + })) +} + +fn swift_position_to_lsp(value: &Value) -> Option { + Some(json!({ + "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), + "character": value + .get("utf16Column") + .or_else(|| value.get("character")) + .and_then(Value::as_i64) + .unwrap_or(0) + })) +} + fn lsp_message_id(message: &Value) -> Option { message.get("id").and_then(|id| match id { Value::String(value) => Some(value.clone()), @@ -1075,6 +1147,11 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - Some("textDocument/completion") => Some(json!({ "items": parse_completion_items(result) })), + Some("completionItem/resolve") => parse_completion_item(result).map(|item| { + json!({ + "item": item + }) + }), Some("textDocument/hover") => Some(json!({ "hover": parse_hover(result) })), @@ -1110,35 +1187,37 @@ fn parse_completion_items(result: &Value) -> Vec { }; values .iter() - .filter_map(|item| { - let label = item.get("label").and_then(Value::as_str)?; - let insert_text = item - .get("insertText") + .filter_map(|item| parse_completion_item(item)) + .collect() +} + +fn parse_completion_item(item: &Value) -> Option { + let label = item.get("label").and_then(Value::as_str)?; + let insert_text = item + .get("insertText") + .and_then(Value::as_str) + .or_else(|| { + item.get("textEdit") + .and_then(|edit| edit.get("newText")) .and_then(Value::as_str) - .or_else(|| { - item.get("textEdit") - .and_then(|edit| edit.get("newText")) - .and_then(Value::as_str) - }) - .unwrap_or(label); - Some(json!({ - "label": label, - "insertText": insert_text, - "kind": item.get("kind").and_then(Value::as_i64), - "detail": item.get("detail").and_then(Value::as_str), - "documentation": completion_documentation(item.get("documentation")), - "sortText": item.get("sortText").and_then(Value::as_str), - "filterText": item.get("filterText").and_then(Value::as_str), - "textEdit": item.get("textEdit").and_then(parse_lsp_text_edit_value), - "additionalTextEdits": item - .get("additionalTextEdits") - .and_then(Value::as_array) - .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) - .unwrap_or_default(), - "data": item.get("data").cloned().unwrap_or(Value::Null) - })) }) - .collect() + .unwrap_or(label); + Some(json!({ + "label": label, + "insertText": insert_text, + "kind": item.get("kind").and_then(Value::as_i64), + "detail": item.get("detail").and_then(Value::as_str), + "documentation": completion_documentation(item.get("documentation")), + "sortText": item.get("sortText").and_then(Value::as_str), + "filterText": item.get("filterText").and_then(Value::as_str), + "textEdit": item.get("textEdit").and_then(parse_lsp_text_edit_value), + "additionalTextEdits": item + .get("additionalTextEdits") + .and_then(Value::as_array) + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default(), + "data": item.get("data").cloned().unwrap_or(Value::Null) + })) } fn completion_documentation(value: Option<&Value>) -> Option { @@ -2358,6 +2437,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_item: None, }) .unwrap(); assert_eq!( @@ -2389,6 +2469,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_item: None, }) .unwrap(); let completed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2433,6 +2514,7 @@ mod tests { new_name: Some("start".to_string()), range: None, diagnostics: Vec::new(), + completion_item: None, }) .unwrap(); let renamed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2469,6 +2551,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_item: None, }) .unwrap(); let formatted = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2525,6 +2608,7 @@ mod tests { source: Some("rust-analyzer".to_string()), code: None, }], + completion_item: None, }) .unwrap(); let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); @@ -2575,6 +2659,62 @@ mod tests { action_result["actions"][0]["command"]["command"], "rust-analyzer.applySourceChange" ); + + let completion_resolve = client_feature_request(ClientFeatureRequest { + state: code_actioned.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "completionItem/resolve".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: Some(json!({ + "label": "launch", + "insertText": "launch", + "kind": 3, + "textEdit": { + "range": { + "start": { "line": 0, "utf16Column": 12 }, + "end": { "line": 0, "utf16Column": 14 } + }, + "newText": "launch" + }, + "data": { "id": "completion-1" } + })), + }) + .unwrap(); + let completion_resolve_request: Value = + serde_json::from_str(&completion_resolve.messages[0]).unwrap(); + assert_eq!( + completion_resolve_request["method"], + "completionItem/resolve" + ); + assert_eq!( + completion_resolve_request["params"]["textEdit"]["range"]["start"]["character"], + 12 + ); + let completion_resolved = client_apply_server_message(ClientApplyServerMessageRequest { + state: completion_resolve.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "5", + "result": { + "label": "launch", + "kind": 3, + "detail": "fn launch()", + "documentation": { "kind": "markdown", "value": "Launches the app." }, + "insertText": "launch", + "data": { "id": "completion-1" } + } + }"# + .to_string(), + }) + .unwrap(); + let completion_resolve_result = completion_resolved.events[0].result.as_ref().unwrap(); + assert_eq!( + completion_resolve_result["item"]["documentation"], + "Launches the app." + ); } #[test] From d362926d6456b343e9139ad1ed01564e1b421e34 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:43:06 +0800 Subject: [PATCH 16/38] Route LSP code action resolve through Rust core --- .../Lithe/Core/Ports/LanguageTooling.swift | 5 + Sources/Lithe/Core/RustCoreBridge.swift | 65 ++++++- .../LanguageToolingSessionManager.swift | 9 +- .../Services/StdioLanguageServerSession.swift | 23 ++- .../RunConfigurationIntegrationTests.swift | 77 +++++++- rust/lithe-core/src/lsp.rs | 184 +++++++++++++++--- 6 files changed, 331 insertions(+), 32 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index c958248e..ce555d30 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -375,6 +375,11 @@ protocol LanguageServerSession: AnyObject { fileURL: URL, completion: @escaping (Result) -> Void ) throws + func resolveCodeAction( + _ action: LanguageServerCodeAction, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws func stop() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 730ca7b1..1f2d5c1c 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -568,6 +568,14 @@ struct RustCoreBridge: Sendable { } } + struct LspCodeActionResolvePayload: Decodable, Sendable { + let action: LspCodeActionsPayload.Action + + func makeModel() -> LanguageServerCodeAction { + action.makeModel() + } + } + struct JavaStructurePayload: Decodable, Sendable { struct FoldRegion: Decodable, Sendable { let kind: String @@ -1075,6 +1083,7 @@ struct RustCoreBridge: Sendable { let range: LspTextEditsRequest.TextEdit.Range? let diagnostics: [LspClientDiagnosticRequest] let completionItem: LspClientCompletionItemRequest? + let codeAction: LspClientCodeActionRequest? } private struct LspClientDiagnosticRequest: Encodable { @@ -1103,6 +1112,25 @@ struct RustCoreBridge: Sendable { let newText: String } + private struct LspClientWorkspaceEditRequest: Encodable { + let changes: [String: [LspClientTextEditRequest]] + } + + private struct LspClientCommandRequest: Encodable { + let title: String + let command: String + let arguments: [ToolingJSONValue] + } + + private struct LspClientCodeActionRequest: Encodable { + let title: String + let kind: String? + let isPreferred: Bool + let edit: LspClientWorkspaceEditRequest? + let command: LspClientCommandRequest? + let data: ToolingJSONValue? + } + private struct LspClientApplyServerMessageRequest: Encodable { let state: ToolingJSONValue let message: String @@ -2091,7 +2119,8 @@ struct RustCoreBridge: Sendable { newName: String? = nil, range: LanguageServerRange? = nil, diagnostics: [LanguageServerDiagnostic] = [], - completionItem: LanguageServerCompletionItem? = nil + completionItem: LanguageServerCompletionItem? = nil, + codeAction: LanguageServerCodeAction? = nil ) -> LspClientResponsePayload? { execute( command: "lsp.clientRequest", @@ -2121,7 +2150,8 @@ struct RustCoreBridge: Sendable { code: $0.code ) }, - completionItem: completionItem.map(Self.makeCompletionItemRequest) + completionItem: completionItem.map(Self.makeCompletionItemRequest), + codeAction: codeAction.map(Self.makeCodeActionRequest) ) ) } @@ -2155,6 +2185,37 @@ struct RustCoreBridge: Sendable { ) } + private static func makeCodeActionRequest( + _ action: LanguageServerCodeAction + ) -> LspClientCodeActionRequest { + LspClientCodeActionRequest( + title: action.title, + kind: action.kind, + isPreferred: action.isPreferred, + edit: action.edit.map(makeWorkspaceEditRequest), + command: action.command.map { + LspClientCommandRequest( + title: $0.title, + command: $0.command, + arguments: $0.arguments + ) + }, + data: action.data + ) + } + + private static func makeWorkspaceEditRequest( + _ edit: LanguageServerWorkspaceEdit + ) -> LspClientWorkspaceEditRequest { + LspClientWorkspaceEditRequest( + changes: Dictionary( + uniqueKeysWithValues: edit.changes.map { url, edits in + (url.standardizedFileURL.path, edits.map(makeTextEditRequest)) + } + ) + ) + } + func lspClientApplyServerMessage( state: ToolingJSONValue, message: String diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 6f97b329..d79fdf37 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -347,7 +347,7 @@ final class LanguageToolingSessionManager: ObservableObject { fileURL: URL, text _: String, rootURL _: URL, - completion _: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) throws { guard action.title.isEmpty == false else { throw LanguageToolingSessionError.capabilityUnavailable( @@ -355,6 +355,13 @@ final class LanguageToolingSessionManager: ObservableObject { capability: "code action resolve" ) } + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.resolveCodeAction(action, fileURL: fileURL, completion: completion) + return + } catch {} + } throw unavailableLanguageServerError(for: fileURL) } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 3e71a06a..658e46c5 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -21,7 +21,8 @@ protocol LspClientCore: Sendable { newName: String?, range: LanguageServerRange?, diagnostics: [LanguageServerDiagnostic], - completionItem: LanguageServerCompletionItem? + completionItem: LanguageServerCompletionItem?, + codeAction: LanguageServerCodeAction? ) -> RustCoreBridge.LspClientResponsePayload? func lspClientApplyServerMessage( state: ToolingJSONValue, @@ -216,6 +217,22 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + func resolveCodeAction( + _ action: LanguageServerCodeAction, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws { + try requestFeature( + method: "codeAction/resolve", + fileURL: fileURL, + position: nil, + codeAction: action + ) { event in + completion(Self.decodeEventResult(event, as: RustCoreBridge.LspCodeActionResolvePayload.self) + .map { $0.makeModel() }) + } + } + func stop() { process.stop() resetTransientState() @@ -254,6 +271,7 @@ final class StdioLanguageServerSession: LanguageServerSession { range: LanguageServerRange? = nil, diagnostics: [LanguageServerDiagnostic] = [], completionItem: LanguageServerCompletionItem? = nil, + codeAction: LanguageServerCodeAction? = nil, completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void ) throws { guard let state, isInitialized else { @@ -270,7 +288,8 @@ final class StdioLanguageServerSession: LanguageServerSession { newName: newName, range: range, diagnostics: diagnostics, - completionItem: completionItem + completionItem: completionItem, + codeAction: codeAction ) else { throw StdioLanguageServerSessionError.requestRejected } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index be898594..32d5eec9 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1221,6 +1221,45 @@ struct RunConfigurationIntegrationTests { let actions = try actionsResult?.get() #expect(actions?.first?.title == "Fix warning") #expect(actions?.first?.command?.command == "source.fix") + + var actionResolveResult: Result? + try manager.resolveCodeAction( + LanguageServerCodeAction( + title: "Fix warning", + kind: "quickfix", + isPreferred: true, + edit: nil, + command: nil, + data: .object(["token": .string("fix-1")]) + ), + fileURL: source, + text: "struct App{ }\n", + rootURL: root + ) { result in + actionResolveResult = result + } + process.emitJSON([ + "jsonrpc": "2.0", + "id": "7", + "result": [ + "title": "Fix warning", + "kind": "quickfix", + "edit": [ + "changes": [ + source.standardizedFileURL.absoluteString: [[ + "range": [ + "start": ["line": 0, "character": 10], + "end": ["line": 0, "character": 10] + ], + "newText": " " + ]] + ] + ], + "data": ["token": "fix-1"] + ] + ]) + await Self.drainMainActorTasks() + #expect(try actionResolveResult?.get().edit?.changes[source.standardizedFileURL]?.first?.newText == " ") } @Test @@ -2896,7 +2935,8 @@ private struct TestLspClientCore: LspClientCore { newName _: String?, range _: LanguageServerRange?, diagnostics _: [LanguageServerDiagnostic], - completionItem _: LanguageServerCompletionItem? + completionItem _: LanguageServerCompletionItem?, + codeAction _: LanguageServerCodeAction? ) -> RustCoreBridge.LspClientResponsePayload? { let id: String switch method { @@ -2908,6 +2948,8 @@ private struct TestLspClientCore: LspClientCore { id = "5" case "completionItem/resolve": id = "6" + case "codeAction/resolve": + id = "7" default: id = "2" } @@ -3068,6 +3110,39 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"7""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "7", + method: "codeAction/resolve", + uri: nil, + diagnostics: nil, + result: .object([ + "action": .object([ + "title": .string("Fix warning"), + "kind": .string("quickfix"), + "isPreferred": .bool(false), + "edit": .object([ + "changes": .object([ + diagnosticURL.standardizedFileURL.path: .array([ + .object([ + "range": .object([ + "start": .object(["line": .integer(0), "utf16Column": .integer(10)]), + "end": .object(["line": .integer(0), "utf16Column": .integer(10)]) + ]), + "newText": .string(" ") + ]) + ]) + ]) + ]), + "data": .object(["token": .string("fix-1")]) + ]) + ]), + error: nil + ) + ]) + } return response( messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index ffe48082..496e8dd6 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -334,6 +334,8 @@ pub struct ClientFeatureRequest { pub diagnostics: Vec, #[serde(default)] pub completion_item: Option, + #[serde(default)] + pub code_action: Option, } #[derive(Debug, Clone, Deserialize)] @@ -973,6 +975,16 @@ fn feature_request_params(request: &ClientFeatureRequest) -> Result request + .code_action + .as_ref() + .map(swift_code_action_to_lsp) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a code action.", + ) + }), _ => Ok(json!({ "textDocument": text_document })), } } @@ -1057,6 +1069,60 @@ fn swift_completion_item_to_lsp(item: &Value) -> Value { Value::Object(object) } +fn swift_code_action_to_lsp(action: &Value) -> Value { + let mut object = serde_json::Map::new(); + copy_string_field(action, &mut object, "title"); + copy_string_field(action, &mut object, "kind"); + if let Some(is_preferred) = action.get("isPreferred").and_then(Value::as_bool) { + object.insert("isPreferred".to_string(), json!(is_preferred)); + } + if let Some(edit) = action.get("edit").and_then(swift_workspace_edit_to_lsp) { + object.insert("edit".to_string(), edit); + } + if let Some(command) = action.get("command").and_then(swift_command_to_lsp) { + object.insert("command".to_string(), command); + } + if let Some(data) = action.get("data") { + object.insert("data".to_string(), data.clone()); + } + Value::Object(object) +} + +fn swift_workspace_edit_to_lsp(value: &Value) -> Option { + let changes = value.get("changes")?.as_object()?; + let mut parsed_changes = serde_json::Map::new(); + for (path, edits) in changes { + let uri = if path.starts_with("file://") { + path.clone() + } else { + format!("file://{path}") + }; + parsed_changes.insert( + uri, + json!(edits + .as_array() + .map(|values| values + .iter() + .filter_map(swift_text_edit_to_lsp) + .collect::>()) + .unwrap_or_default()), + ); + } + Some(json!({ "changes": parsed_changes })) +} + +fn swift_command_to_lsp(value: &Value) -> Option { + Some(json!({ + "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), + "command": value.get("command").and_then(Value::as_str)?, + "arguments": value + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + })) +} + fn copy_string_field(source: &Value, target: &mut serde_json::Map, field: &str) { if let Some(value) = source.get(field).and_then(Value::as_str) { target.insert(field.to_string(), json!(value)); @@ -1167,6 +1233,11 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - Some("textDocument/codeAction") => Some(json!({ "actions": parse_code_actions(result) })), + Some("codeAction/resolve") => parse_code_action(result).map(|action| { + json!({ + "action": action + }) + }), Some("textDocument/definition") | Some("textDocument/declaration") | Some("textDocument/typeDefinition") @@ -1311,30 +1382,29 @@ fn parse_code_actions(result: &Value) -> Vec { let Some(values) = result.as_array() else { return Vec::new(); }; - values - .iter() - .filter_map(|action| { - let title = action.get("title").and_then(Value::as_str)?; - let command = if action.get("command").and_then(Value::as_str).is_some() { - parse_lsp_command(action) - } else { - action.get("command").and_then(parse_lsp_command) - }; - Some(json!({ - "title": title, - "kind": action.get("kind").and_then(Value::as_str), - "isPreferred": action - .get("isPreferred") - .and_then(Value::as_bool) - .unwrap_or(false), - "edit": action.get("edit").map(|edit| json!({ - "changes": parse_workspace_edit(edit) - })), - "command": command, - "data": action.get("data").cloned().unwrap_or(Value::Null) - })) - }) - .collect() + values.iter().filter_map(parse_code_action).collect() +} + +fn parse_code_action(action: &Value) -> Option { + let title = action.get("title").and_then(Value::as_str)?; + let command = if action.get("command").and_then(Value::as_str).is_some() { + parse_lsp_command(action) + } else { + action.get("command").and_then(parse_lsp_command) + }; + Some(json!({ + "title": title, + "kind": action.get("kind").and_then(Value::as_str), + "isPreferred": action + .get("isPreferred") + .and_then(Value::as_bool) + .unwrap_or(false), + "edit": action.get("edit").map(|edit| json!({ + "changes": parse_workspace_edit(edit) + })), + "command": command, + "data": action.get("data").cloned().unwrap_or(Value::Null) + })) } fn parse_lsp_command(value: &Value) -> Option { @@ -2438,6 +2508,7 @@ mod tests { range: None, diagnostics: Vec::new(), completion_item: None, + code_action: None, }) .unwrap(); assert_eq!( @@ -2470,6 +2541,7 @@ mod tests { range: None, diagnostics: Vec::new(), completion_item: None, + code_action: None, }) .unwrap(); let completed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2515,6 +2587,7 @@ mod tests { range: None, diagnostics: Vec::new(), completion_item: None, + code_action: None, }) .unwrap(); let renamed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2552,6 +2625,7 @@ mod tests { range: None, diagnostics: Vec::new(), completion_item: None, + code_action: None, }) .unwrap(); let formatted = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2609,6 +2683,7 @@ mod tests { code: None, }], completion_item: None, + code_action: None, }) .unwrap(); let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); @@ -2660,9 +2735,65 @@ mod tests { "rust-analyzer.applySourceChange" ); - let completion_resolve = client_feature_request(ClientFeatureRequest { + let code_action_resolve = client_feature_request(ClientFeatureRequest { state: code_actioned.state, uri: "file:///tmp/project/main.rs".to_string(), + method: "codeAction/resolve".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: Some(json!({ + "title": "Apply rename", + "kind": "quickfix", + "isPreferred": true, + "data": { "id": "action-1" } + })), + }) + .unwrap(); + let code_action_resolve_request: Value = + serde_json::from_str(&code_action_resolve.messages[0]).unwrap(); + assert_eq!(code_action_resolve_request["method"], "codeAction/resolve"); + assert_eq!( + code_action_resolve_request["params"]["title"], + "Apply rename" + ); + let code_action_resolved = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_action_resolve.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "5", + "result": { + "title": "Apply rename", + "kind": "quickfix", + "edit": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + }, + "data": { "id": "action-1" } + } + }"# + .to_string(), + }) + .unwrap(); + let code_action_resolve_result = code_action_resolved.events[0].result.as_ref().unwrap(); + assert_eq!( + code_action_resolve_result["action"]["edit"]["changes"]["/tmp/project/main.rs"][0] + ["newText"], + "start" + ); + + let completion_resolve = client_feature_request(ClientFeatureRequest { + state: code_action_resolved.state, + uri: "file:///tmp/project/main.rs".to_string(), method: "completionItem/resolve".to_string(), position: None, new_name: None, @@ -2681,6 +2812,7 @@ mod tests { }, "data": { "id": "completion-1" } })), + code_action: None, }) .unwrap(); let completion_resolve_request: Value = @@ -2697,7 +2829,7 @@ mod tests { state: completion_resolve.state, message: r#"{ "jsonrpc": "2.0", - "id": "5", + "id": "6", "result": { "label": "launch", "kind": 3, From bb7f24fe9005467bb16a293d7d88690457da29bb Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:46:40 +0800 Subject: [PATCH 17/38] Route LSP execute command through Rust core --- .../Lithe/Core/Ports/LanguageTooling.swift | 5 ++ Sources/Lithe/Core/RustCoreBridge.swift | 23 +++++--- .../LanguageToolingSessionManager.swift | 9 ++- .../Services/StdioLanguageServerSession.swift | 26 ++++++++- .../RunConfigurationIntegrationTests.swift | 40 +++++++++++++- rust/lithe-core/src/lsp.rs | 55 +++++++++++++++++++ 6 files changed, 147 insertions(+), 11 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index ce555d30..02e5ff62 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -380,6 +380,11 @@ protocol LanguageServerSession: AnyObject { fileURL: URL, completion: @escaping (Result) -> Void ) throws + func execute( + _ command: LanguageServerCommand, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws func stop() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 1f2d5c1c..c7650aa5 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1084,6 +1084,7 @@ struct RustCoreBridge: Sendable { let diagnostics: [LspClientDiagnosticRequest] let completionItem: LspClientCompletionItemRequest? let codeAction: LspClientCodeActionRequest? + let command: LspClientCommandRequest? } private struct LspClientDiagnosticRequest: Encodable { @@ -2120,7 +2121,8 @@ struct RustCoreBridge: Sendable { range: LanguageServerRange? = nil, diagnostics: [LanguageServerDiagnostic] = [], completionItem: LanguageServerCompletionItem? = nil, - codeAction: LanguageServerCodeAction? = nil + codeAction: LanguageServerCodeAction? = nil, + command: LanguageServerCommand? = nil ) -> LspClientResponsePayload? { execute( command: "lsp.clientRequest", @@ -2151,7 +2153,8 @@ struct RustCoreBridge: Sendable { ) }, completionItem: completionItem.map(Self.makeCompletionItemRequest), - codeAction: codeAction.map(Self.makeCodeActionRequest) + codeAction: codeAction.map(Self.makeCodeActionRequest), + command: command.map(Self.makeCommandRequest) ) ) } @@ -2194,16 +2197,22 @@ struct RustCoreBridge: Sendable { isPreferred: action.isPreferred, edit: action.edit.map(makeWorkspaceEditRequest), command: action.command.map { - LspClientCommandRequest( - title: $0.title, - command: $0.command, - arguments: $0.arguments - ) + makeCommandRequest($0) }, data: action.data ) } + private static func makeCommandRequest( + _ command: LanguageServerCommand + ) -> LspClientCommandRequest { + LspClientCommandRequest( + title: command.title, + command: command.command, + arguments: command.arguments + ) + } + private static func makeWorkspaceEditRequest( _ edit: LanguageServerWorkspaceEdit ) -> LspClientWorkspaceEditRequest { diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index d79fdf37..dd3d66a2 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -308,7 +308,7 @@ final class LanguageToolingSessionManager: ObservableObject { fileURL: URL, text _: String, rootURL _: URL, - completion _: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) throws { guard command.command.isEmpty == false else { throw LanguageToolingSessionError.capabilityUnavailable( @@ -316,6 +316,13 @@ final class LanguageToolingSessionManager: ObservableObject { capability: "execute command" ) } + if let session = languageServerSession(for: fileURL), + session.isRunning { + do { + try session.execute(command, fileURL: fileURL, completion: completion) + return + } catch {} + } throw unavailableLanguageServerError(for: fileURL) } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 658e46c5..f6aa1f3e 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -22,7 +22,8 @@ protocol LspClientCore: Sendable { range: LanguageServerRange?, diagnostics: [LanguageServerDiagnostic], completionItem: LanguageServerCompletionItem?, - codeAction: LanguageServerCodeAction? + codeAction: LanguageServerCodeAction?, + command: LanguageServerCommand? ) -> RustCoreBridge.LspClientResponsePayload? func lspClientApplyServerMessage( state: ToolingJSONValue, @@ -233,6 +234,25 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + func execute( + _ command: LanguageServerCommand, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws { + try requestFeature( + method: "workspace/executeCommand", + fileURL: fileURL, + position: nil, + command: command + ) { event in + if let error = event.error { + completion(.failure(StdioLanguageServerSessionError.serverError(error))) + } else { + completion(.success(())) + } + } + } + func stop() { process.stop() resetTransientState() @@ -272,6 +292,7 @@ final class StdioLanguageServerSession: LanguageServerSession { diagnostics: [LanguageServerDiagnostic] = [], completionItem: LanguageServerCompletionItem? = nil, codeAction: LanguageServerCodeAction? = nil, + command: LanguageServerCommand? = nil, completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void ) throws { guard let state, isInitialized else { @@ -289,7 +310,8 @@ final class StdioLanguageServerSession: LanguageServerSession { range: range, diagnostics: diagnostics, completionItem: completionItem, - codeAction: codeAction + codeAction: codeAction, + command: command ) else { throw StdioLanguageServerSessionError.requestRejected } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 32d5eec9..34cce44c 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1260,6 +1260,28 @@ struct RunConfigurationIntegrationTests { ]) await Self.drainMainActorTasks() #expect(try actionResolveResult?.get().edit?.changes[source.standardizedFileURL]?.first?.newText == " ") + + var executeResult: Result? + try manager.execute( + LanguageServerCommand( + title: "Apply fix", + command: "source.fix", + arguments: [.object(["uri": .string(source.standardizedFileURL.absoluteString)])] + ), + fileURL: source, + text: "struct App{ }\n", + rootURL: root + ) { result in + executeResult = result + } + process.emitJSON([ + "jsonrpc": "2.0", + "id": "8", + "result": NSNull() + ]) + await Self.drainMainActorTasks() + #expect(executeResult != nil) + try executeResult?.get() } @Test @@ -2936,7 +2958,8 @@ private struct TestLspClientCore: LspClientCore { range _: LanguageServerRange?, diagnostics _: [LanguageServerDiagnostic], completionItem _: LanguageServerCompletionItem?, - codeAction _: LanguageServerCodeAction? + codeAction _: LanguageServerCodeAction?, + command _: LanguageServerCommand? ) -> RustCoreBridge.LspClientResponsePayload? { let id: String switch method { @@ -2950,6 +2973,8 @@ private struct TestLspClientCore: LspClientCore { id = "6" case "codeAction/resolve": id = "7" + case "workspace/executeCommand": + id = "8" default: id = "2" } @@ -3143,6 +3168,19 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"8""#) { + return response(events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "8", + method: "workspace/executeCommand", + uri: nil, + diagnostics: nil, + result: .object(["ok": .bool(true)]), + error: nil + ) + ]) + } return response( messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index 496e8dd6..0b97536b 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -336,6 +336,8 @@ pub struct ClientFeatureRequest { pub completion_item: Option, #[serde(default)] pub code_action: Option, + #[serde(default)] + pub command: Option, } #[derive(Debug, Clone, Deserialize)] @@ -985,6 +987,16 @@ fn feature_request_params(request: &ClientFeatureRequest) -> Result request + .command + .as_ref() + .and_then(swift_command_to_lsp) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a command.", + ) + }), _ => Ok(json!({ "textDocument": text_document })), } } @@ -1238,6 +1250,7 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - "action": action }) }), + Some("workspace/executeCommand") => Some(json!({ "ok": true })), Some("textDocument/definition") | Some("textDocument/declaration") | Some("textDocument/typeDefinition") @@ -2509,6 +2522,7 @@ mod tests { diagnostics: Vec::new(), completion_item: None, code_action: None, + command: None, }) .unwrap(); assert_eq!( @@ -2542,6 +2556,7 @@ mod tests { diagnostics: Vec::new(), completion_item: None, code_action: None, + command: None, }) .unwrap(); let completed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2588,6 +2603,7 @@ mod tests { diagnostics: Vec::new(), completion_item: None, code_action: None, + command: None, }) .unwrap(); let renamed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2626,6 +2642,7 @@ mod tests { diagnostics: Vec::new(), completion_item: None, code_action: None, + command: None, }) .unwrap(); let formatted = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2684,6 +2701,7 @@ mod tests { }], completion_item: None, code_action: None, + command: None, }) .unwrap(); let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); @@ -2750,6 +2768,7 @@ mod tests { "isPreferred": true, "data": { "id": "action-1" } })), + command: None, }) .unwrap(); let code_action_resolve_request: Value = @@ -2813,6 +2832,7 @@ mod tests { "data": { "id": "completion-1" } })), code_action: None, + command: None, }) .unwrap(); let completion_resolve_request: Value = @@ -2847,6 +2867,41 @@ mod tests { completion_resolve_result["item"]["documentation"], "Launches the app." ); + + let execute_command = client_feature_request(ClientFeatureRequest { + state: completion_resolved.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "workspace/executeCommand".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: Some(json!({ + "title": "Apply", + "command": "rust-analyzer.applySourceChange", + "arguments": [{ "label": "rename" }] + })), + }) + .unwrap(); + let execute_request: Value = serde_json::from_str(&execute_command.messages[0]).unwrap(); + assert_eq!(execute_request["method"], "workspace/executeCommand"); + assert_eq!( + execute_request["params"]["command"], + "rust-analyzer.applySourceChange" + ); + let executed = client_apply_server_message(ClientApplyServerMessageRequest { + state: execute_command.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "7", + "result": null + }"# + .to_string(), + }) + .unwrap(); + assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); } #[test] From 59c8acf92ddbf1802234b1be1156a42c3654aceb Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:51:43 +0800 Subject: [PATCH 18/38] Move LSP outbound framing into Rust core --- Sources/Lithe/Core/RustCoreBridge.swift | 15 +++++++ .../Services/StdioLanguageServerSession.swift | 12 ++++-- .../RunConfigurationIntegrationTests.swift | 12 +++++- rust/lithe-core/src/command.rs | 2 + rust/lithe-core/src/lsp.rs | 42 +++++++++++++++++++ rust/lithe-core/src/runtime.rs | 15 +++++++ 6 files changed, 94 insertions(+), 4 deletions(-) diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index c7650aa5..ad5bd8e4 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1137,6 +1137,14 @@ struct RustCoreBridge: Sendable { let message: String } + struct LspFramePayload: Decodable, Sendable { + let frame: String + } + + private struct LspFrameRequest: Encodable { + let message: String + } + private struct MavenDiagnosticsRequest: Encodable { let root: String let output: String @@ -2235,6 +2243,13 @@ struct RustCoreBridge: Sendable { ) } + func lspFrameMessage(_ message: String) -> LspFramePayload? { + execute( + command: "lsp.frameMessage", + payload: LspFrameRequest(message: message) + ) + } + private func execute( command: String, payload: Payload diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index f6aa1f3e..9fac2b5c 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -29,6 +29,7 @@ protocol LspClientCore: Sendable { state: ToolingJSONValue, message: String ) -> RustCoreBridge.LspClientResponsePayload? + func lspFrameMessage(_ message: String) -> RustCoreBridge.LspFramePayload? } extension RustCoreBridge: LspClientCore {} @@ -338,10 +339,15 @@ final class StdioLanguageServerSession: LanguageServerSession { } private func sendRawJSON(_ message: String) { + if let frame = core.lspFrameMessage(message)?.frame, + let data = frame.data(using: .utf8) { + try? process.send(data) + return + } guard let body = message.data(using: .utf8) else { return } - var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8) - framed.append(body) - try? process.send(framed) + var fallback = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + fallback.append(body) + try? process.send(fallback) } private func receive(_ data: Data) { diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 34cce44c..d6fa49a5 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1031,7 +1031,11 @@ struct RunConfigurationIntegrationTests { #expect(startRequest.executablePath == "/usr/bin/sourcekit-lsp") #expect(startRequest.arguments.isEmpty) #expect(manager.activeLanguageServerIDs == ["swift"]) - #expect(String(data: try #require(process.sentData.first), encoding: .utf8)?.contains("\"method\":\"initialize\"") == true) + let firstFrameData = try #require(process.sentData.first) + let firstFrame = try #require(String(data: firstFrameData, encoding: .utf8)) + #expect(firstFrame.hasPrefix("Content-Length: ")) + #expect(firstFrame.contains("\r\n\r\n{\"jsonrpc\"")) + #expect(firstFrame.contains("\"method\":\"initialize\"")) process.emitJSON([ "jsonrpc": "2.0", @@ -3197,6 +3201,12 @@ private struct TestLspClientCore: LspClientCore { ) } + func lspFrameMessage(_ message: String) -> RustCoreBridge.LspFramePayload? { + RustCoreBridge.LspFramePayload( + frame: "Content-Length: \(message.utf8.count)\r\n\r\n\(message)" + ) + } + private func response( messages: [String] = [], events: [RustCoreBridge.LspClientEventPayload] = [] diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index faf586fe..01df8500 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -41,6 +41,7 @@ pub enum CoreCommand { LspClientChangeDocument, LspClientRequest, LspClientApplyServerMessage, + LspFrameMessage, JavaRunConfigurations, RunConfigInspect, RunConfigGenerate, @@ -98,6 +99,7 @@ impl CoreCommand { "lsp.clientChangeDocument" => Some(Self::LspClientChangeDocument), "lsp.clientRequest" => Some(Self::LspClientRequest), "lsp.clientApplyServerMessage" => Some(Self::LspClientApplyServerMessage), + "lsp.frameMessage" => Some(Self::LspFrameMessage), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), "runConfig.generate" => Some(Self::RunConfigGenerate), diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index 0b97536b..d6c27669 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -348,6 +348,18 @@ pub struct ClientApplyServerMessageRequest { pub message: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameMessageRequest { + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameMessageResponse { + pub frame: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspClientResponse { @@ -749,6 +761,22 @@ pub fn client_apply_server_message( Ok(client_response(state, responses, events)) } +pub fn frame_message(request: FrameMessageRequest) -> Result { + if request.message.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP message frame cannot contain NUL bytes.", + )); + } + Ok(FrameMessageResponse { + frame: format!( + "Content-Length: {}\r\n\r\n{}", + request.message.len(), + request.message + ), + }) +} + pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { let mut diagnostics = Vec::new(); let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { @@ -2534,6 +2562,20 @@ mod tests { assert_eq!(request_message["params"]["position"]["character"], 12); } + #[test] + fn frame_message_uses_lsp_content_length_bytes() { + let message = + r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"你好"}}"#; + let framed = frame_message(FrameMessageRequest { + message: message.to_string(), + }) + .unwrap(); + assert!(framed + .frame + .starts_with(&format!("Content-Length: {}\r\n\r\n", message.len()))); + assert!(framed.frame.ends_with(message)); + } + #[test] fn client_core_shapes_feature_responses_for_swift_models() { let opened = client_open_document(ClientOpenDocumentRequest { diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 3cec3a5f..74688deb 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -425,6 +425,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspFrameMessage => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP frame request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::frame_message) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP frame response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::JavaRunConfigurations => { match serde_json::from_value::(parsed.payload) .map_err(|error| { From 4749a43c02ede77d59e1687cbfbadf1705faf386 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 01:59:12 +0800 Subject: [PATCH 19/38] Move LSP inbound framing parser into Rust core --- Sources/Lithe/Core/RustCoreBridge.swift | 20 ++++ .../Services/StdioLanguageServerSession.swift | 30 +++--- .../RunConfigurationIntegrationTests.swift | 29 ++++++ rust/lithe-core/src/command.rs | 2 + rust/lithe-core/src/lsp.rs | 93 +++++++++++++++++++ rust/lithe-core/src/runtime.rs | 15 +++ 6 files changed, 170 insertions(+), 19 deletions(-) diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index ad5bd8e4..0eea70c9 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1145,6 +1145,16 @@ struct RustCoreBridge: Sendable { let message: String } + struct LspParsedMessagesPayload: Decodable, Sendable { + let buffer: [UInt8] + let messages: [String] + } + + private struct LspParseServerMessagesRequest: Encodable { + let buffer: [UInt8] + let chunk: [UInt8] + } + private struct MavenDiagnosticsRequest: Encodable { let root: String let output: String @@ -2250,6 +2260,16 @@ struct RustCoreBridge: Sendable { ) } + func lspParseServerMessages( + buffer: [UInt8], + chunk: [UInt8] + ) -> LspParsedMessagesPayload? { + execute( + command: "lsp.parseServerMessages", + payload: LspParseServerMessagesRequest(buffer: buffer, chunk: chunk) + ) + } + private func execute( command: String, payload: Payload diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 9fac2b5c..2b1693db 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -30,6 +30,10 @@ protocol LspClientCore: Sendable { message: String ) -> RustCoreBridge.LspClientResponsePayload? func lspFrameMessage(_ message: String) -> RustCoreBridge.LspFramePayload? + func lspParseServerMessages( + buffer: [UInt8], + chunk: [UInt8] + ) -> RustCoreBridge.LspParsedMessagesPayload? } extension RustCoreBridge: LspClientCore {} @@ -351,25 +355,13 @@ final class StdioLanguageServerSession: LanguageServerSession { } private func receive(_ data: Data) { - readBuffer.append(data) - while let headerEnd = readBuffer.range(of: Data("\r\n\r\n".utf8)) { - let headerData = readBuffer[..= bodyStart + contentLength else { return } - let body = readBuffer.subdata(in: bodyStart..<(bodyStart + contentLength)) - readBuffer.removeSubrange(0..<(bodyStart + contentLength)) - guard let message = String(data: body, encoding: .utf8), - let state else { continue } + guard let parsed = core.lspParseServerMessages( + buffer: Array(readBuffer), + chunk: Array(data) + ) else { return } + readBuffer = Data(parsed.buffer) + for message in parsed.messages { + guard let state else { continue } if let response = core.lspClientApplyServerMessage(state: state, message: message) { apply(response) } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index d6fa49a5..bc5945cf 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -3207,6 +3207,35 @@ private struct TestLspClientCore: LspClientCore { ) } + func lspParseServerMessages( + buffer: [UInt8], + chunk: [UInt8] + ) -> RustCoreBridge.LspParsedMessagesPayload? { + var data = Data(buffer + chunk) + var messages: [String] = [] + while let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) { + let headerData = data[..= bodyStart + contentLength else { break } + let body = data.subdata(in: bodyStart..<(bodyStart + contentLength)) + data.removeSubrange(0..<(bodyStart + contentLength)) + if let message = String(data: body, encoding: .utf8) { + messages.append(message) + } + } + return RustCoreBridge.LspParsedMessagesPayload(buffer: Array(data), messages: messages) + } + private func response( messages: [String] = [], events: [RustCoreBridge.LspClientEventPayload] = [] diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index 01df8500..03497d3a 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -42,6 +42,7 @@ pub enum CoreCommand { LspClientRequest, LspClientApplyServerMessage, LspFrameMessage, + LspParseServerMessages, JavaRunConfigurations, RunConfigInspect, RunConfigGenerate, @@ -100,6 +101,7 @@ impl CoreCommand { "lsp.clientRequest" => Some(Self::LspClientRequest), "lsp.clientApplyServerMessage" => Some(Self::LspClientApplyServerMessage), "lsp.frameMessage" => Some(Self::LspFrameMessage), + "lsp.parseServerMessages" => Some(Self::LspParseServerMessages), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), "runConfig.generate" => Some(Self::RunConfigGenerate), diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index d6c27669..ec54a016 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -360,6 +360,22 @@ pub struct FrameMessageResponse { pub frame: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseServerMessagesRequest { + #[serde(default)] + pub buffer: Vec, + #[serde(default)] + pub chunk: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseServerMessagesResponse { + pub buffer: Vec, + pub messages: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspClientResponse { @@ -777,6 +793,34 @@ pub fn frame_message(request: FrameMessageRequest) -> Result Result { + let mut buffer = request.buffer; + buffer.extend(request.chunk); + let mut messages = Vec::new(); + + while let Some(header_end) = find_header_end(&buffer) { + let header = String::from_utf8_lossy(&buffer[..header_end]); + let Some(content_length) = content_length_from_header(&header) else { + buffer.drain(..header_end + 4); + continue; + }; + let body_start = header_end + 4; + let body_end = body_start + content_length; + if buffer.len() < body_end { + break; + } + let body = buffer[body_start..body_end].to_vec(); + buffer.drain(..body_end); + if let Ok(message) = String::from_utf8(body) { + messages.push(message); + } + } + + Ok(ParseServerMessagesResponse { buffer, messages }) +} + pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { let mut diagnostics = Vec::new(); let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { @@ -918,6 +962,21 @@ fn encode_json_rpc(value: Value) -> Result { }) } +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn content_length_from_header(header: &str) -> Option { + header.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("content-length") { + value.trim().parse().ok() + } else { + None + } + }) +} + fn validate_uri(value: &str) -> Result<(), CoreError> { if value.trim().is_empty() || value.contains('\0') { Err(CoreError::new( @@ -2576,6 +2635,40 @@ mod tests { assert!(framed.frame.ends_with(message)); } + #[test] + fn parse_server_messages_returns_complete_messages_and_remaining_buffer() { + let first = r#"{"jsonrpc":"2.0","id":1,"result":null}"#; + let second = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"ok"}}"#; + let first_frame = frame_message(FrameMessageRequest { + message: first.to_string(), + }) + .unwrap() + .frame; + let second_frame = frame_message(FrameMessageRequest { + message: second.to_string(), + }) + .unwrap() + .frame; + let split_at = first_frame.len() - 3; + let partial = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: first_frame.as_bytes()[..split_at].to_vec(), + }) + .unwrap(); + assert!(partial.messages.is_empty()); + assert_eq!(partial.buffer, first_frame.as_bytes()[..split_at]); + + let mut next_chunk = first_frame.as_bytes()[split_at..].to_vec(); + next_chunk.extend(second_frame.as_bytes()); + let parsed = parse_server_messages(ParseServerMessagesRequest { + buffer: partial.buffer, + chunk: next_chunk, + }) + .unwrap(); + assert_eq!(parsed.messages, vec![first.to_string(), second.to_string()]); + assert!(parsed.buffer.is_empty()); + } + #[test] fn client_core_shapes_feature_responses_for_swift_models() { let opened = client_open_document(ClientOpenDocumentRequest { diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 74688deb..e1e5e30d 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -440,6 +440,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspParseServerMessages => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP parser request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::parse_server_messages) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP parser response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::JavaRunConfigurations => { match serde_json::from_value::(parsed.payload) .map_err(|error| { From ea515bef9b4342dcf0cf0d762d5b300efba5aa3f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 02:25:49 +0800 Subject: [PATCH 20/38] Generalize language tooling and validate gopls --- README.md | 6 +- README.zh-CN.md | 6 +- .../Lithe/Core/Ports/LanguageTooling.swift | 12 + Sources/Lithe/Core/RustCoreBridge.swift | 29 ++ .../MacOS/Process/MacRawProcessSession.swift | 14 +- .../Runtime/MacRuntimeToolDiscovery.swift | 32 ++ .../Services/LanguageFeatureProvider.swift | 369 +++++++++++++++ .../LanguageToolingSessionManager.swift | 316 ++++++++++--- .../Services/StdioLanguageServerSession.swift | 104 ++++- .../LanguageFeatureProviderTests.swift | 106 +++++ .../RealGoplsIntegrationTests.swift | 146 ++++++ .../RunConfigurationIntegrationTests.swift | 105 ++++- docs/architecture/language-tooling.md | 149 ++++++ docs/architecture/mac-service-boundaries.md | 25 +- docs/architecture/repository-layout.md | 7 +- ...02\345\274\200\345\217\221\344\271\246.md" | 14 +- rust/lithe-core/src/command.rs | 21 + rust/lithe-core/src/lsp.rs | 425 +++++++++++++++++- rust/lithe-core/src/runtime.rs | 98 ++++ shared/contracts/application-boundary.md | 15 +- shared/contracts/rust-core-api.md | 21 +- 21 files changed, 1892 insertions(+), 128 deletions(-) create mode 100644 Sources/Lithe/Services/LanguageFeatureProvider.swift create mode 100644 Tests/LitheTests/LanguageFeatureProviderTests.swift create mode 100644 Tests/LitheTests/RealGoplsIntegrationTests.swift create mode 100644 docs/architecture/language-tooling.md diff --git a/README.md b/README.md index a4b9935d..fabcb673 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ ## About Lithe -Lithe is a native macOS IDE built for AI-assisted development. It preserves the project browsing, editing, search, code navigation, Git, run, and debug workflows familiar to IntelliJ IDEA users while starting Java language services, terminals, Maven, and debug processes only when needed. +Lithe is a native macOS IDE built for AI-assisted development. It preserves the project browsing, editing, search, code navigation, Git, run, and debug workflows familiar to IntelliJ IDEA users while starting language servers, terminals, Maven, and debug processes only when needed. When an external AI tool changes a project, Lithe helps you locate the affected code, run the project, review the diff, and decide which changes to stage, undo, or commit. @@ -61,7 +61,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected 2. Maven management, breakpoint debugging, and custom run configurations. 3. Git management and side-by-side diff review. 4. Double-Shift search and `Command + Shift + F` project-wide search. -5. Code navigation and reference lookup. +5. Process-free lightweight completion and current-file navigation, with on-demand language servers for richer completion, hover, and semantic navigation. 6. Local snapshot history. 7. Multiple projects open within the app. 8. Multiple files open independently in the same window. @@ -104,7 +104,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected ## Use Lithe -Lithe requires macOS 14 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Maven projects need either a project `mvnw` or a system Maven installation. Semantic navigation is routed through Lithe's Rust LSP host. +Lithe requires macOS 14 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Maven projects need either a project `mvnw` or a system Maven installation. Lightweight completion does not start an external process; when a matching language server is installed, Lithe routes the capabilities it actually advertises through the shared Rust LSP Core. See the [language tooling and LSP architecture](./docs/architecture/language-tooling.md) for provider configuration and compatibility details. Download the latest macOS `.dmg` from [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest). If a release provides architecture-specific installers, choose `arm64` for Apple silicon or `x86_64` for an Intel Mac. Open the disk image, drag `Lithe.app` into `/Applications`, and launch it. diff --git a/README.zh-CN.md b/README.zh-CN.md index b2ca1870..1106cb60 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ ## 项目简介 -Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ IDEA 用户熟悉的项目浏览、编辑、搜索、代码导航、Git、运行和调试工作流,同时让 Java 语言服务、终端、Maven 和调试进程只在需要时启动。 +Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ IDEA 用户熟悉的项目浏览、编辑、搜索、代码导航、Git、运行和调试工作流,同时让语言服务器、终端、Maven 和调试进程只在需要时启动。 当外部 AI 工具修改项目后,你可以用 Lithe 定位受影响的代码、运行项目、审查 Diff,并决定暂存、撤销或提交哪些修改。 @@ -61,7 +61,7 @@ Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ ID 2. 支持 Maven 管理、断点调试和自定义启动配置。 3. 支持 Git 管理和 Diff 审查。 4. 支持双击 Shift 搜索,以及 `Command + Shift + F` 全局搜索。 -5. 支持代码跳转和代码引用查找。 +5. 支持无需 LSP 的轻量补全和当前文件导航,并可按需使用语言服务器增强补全、悬浮与语义导航。 6. 支持本地快照保存。 7. 支持在应用内打开多个项目。 8. 支持在同一窗口打开多个文件,各文件相互独立。 @@ -104,7 +104,7 @@ Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ ID ## 如何使用 -Lithe 需要 macOS 14 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。语义导航会通过 Lithe 的 Rust LSP host 提供。 +Lithe 需要 macOS 14 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。轻量补全无需启动外部进程;安装相应语言服务器后,Lithe 会通过共享 Rust LSP Core 按服务器实际声明的能力提供语义功能。详细设计与自定义 provider 配置见[语言工具与 LSP 架构](./docs/architecture/language-tooling.md)。 从 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest) 下载最新的 macOS `.dmg`。如果该版本提供独立架构安装包,M 系列芯片选择 `arm64`,Intel 芯片选择 `x86_64`。打开磁盘映像,将 `Lithe.app` 拖入 `/Applications` 后启动。 diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 02e5ff62..ec197dfe 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -336,8 +336,11 @@ extension LanguageTestProvider { protocol LanguageServerSession: AnyObject { var isRunning: Bool { get } var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } + var features: LanguageServerFeatureSet { get } + var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get set } func start(rootURL: URL) throws func synchronize(fileURL: URL, text: String, languageID: String) throws + func closeDocument(_ fileURL: URL) func completions( fileURL: URL, position: LanguageServerPosition, @@ -388,6 +391,15 @@ protocol LanguageServerSession: AnyObject { func stop() } +extension LanguageServerSession { + var features: LanguageServerFeatureSet { [] } + var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { + get { nil } + set {} + } + func closeDocument(_: URL) {} +} + @MainActor protocol DebugAdapterSession: AnyObject { var isRunning: Bool { get } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 0eea70c9..3b7ebab8 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1074,6 +1074,15 @@ struct RustCoreBridge: Sendable { let text: String } + private struct LspClientCloseDocumentRequest: Encodable { + let state: ToolingJSONValue + let uri: String + } + + private struct LspClientShutdownRequest: Encodable { + let state: ToolingJSONValue + } + private struct LspClientFeatureRequest: Encodable { let state: ToolingJSONValue let uri: String @@ -2130,6 +2139,26 @@ struct RustCoreBridge: Sendable { ) } + func lspClientCloseDocument( + state: ToolingJSONValue, + fileURL: URL + ) -> LspClientResponsePayload? { + execute( + command: "lsp.clientCloseDocument", + payload: LspClientCloseDocumentRequest( + state: state, + uri: fileURL.standardizedFileURL.absoluteString + ) + ) + } + + func lspClientShutdown(state: ToolingJSONValue) -> LspClientResponsePayload? { + execute( + command: "lsp.clientShutdown", + payload: LspClientShutdownRequest(state: state) + ) + } + func lspClientRequest( state: ToolingJSONValue, fileURL: URL, diff --git a/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift b/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift index 731b201d..efbf535b 100644 --- a/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift +++ b/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift @@ -74,9 +74,19 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable { self.onTermination?(terminatedProcess.terminationStatus) } + self.process = process + self.inputPipe = inputPipe + self.outputPipe = outputPipe + self.errorPipe = errorPipe do { try process.run() } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + self.process = nil + self.inputPipe = nil + self.outputPipe = nil + self.errorPipe = nil onStateChange?(ProcessLifecycleEvent( operationID: request.operationID, state: .failed, @@ -86,10 +96,6 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable { activeOperationID = nil throw error } - self.process = process - self.inputPipe = inputPipe - self.outputPipe = outputPipe - self.errorPipe = errorPipe if let input = request.standardInput, let inputPipe { try inputPipe.fileHandleForWriting.write(contentsOf: input) if !request.keepsStandardInputOpen { diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index cc506432..51e7df07 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -70,6 +70,16 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { ) } + if shouldSearchGoUserBin(for: command) { + for directory in goUserBinDirectories(environment: environment) { + add( + directory.appendingPathComponent(command), + source: .environment, + detail: "Go user bin: \(directory.path)" + ) + } + } + let homebrewRoots = [ "/opt/homebrew/bin", "/usr/local/bin", @@ -195,4 +205,26 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { || path.hasPrefix("/opt/homebrew/opt/") || path.hasPrefix("/usr/local/opt/") } + + private func goUserBinDirectories(environment: [String: String]) -> [URL] { + var directories: [URL] = [] + if let goBin = configuredURL(environment["GOBIN"] ?? "", projectURL: nil) { + directories.append(goBin) + } + for goPath in (environment["GOPATH"] ?? "").split(separator: ":") where !goPath.isEmpty { + directories.append(URL(fileURLWithPath: String(goPath)).appendingPathComponent("bin")) + } + directories.append(homeDirectoryURL.appendingPathComponent("go/bin")) + directories.append(homeDirectoryURL.appendingPathComponent(".go/bin")) + return directories + } + + private func shouldSearchGoUserBin(for command: String) -> Bool { + switch command { + case "dlv", "gofumpt", "goimports", "gomodifytags", "gopls", "staticcheck": + return true + default: + return false + } + } } diff --git a/Sources/Lithe/Services/LanguageFeatureProvider.swift b/Sources/Lithe/Services/LanguageFeatureProvider.swift new file mode 100644 index 00000000..7eb532d2 --- /dev/null +++ b/Sources/Lithe/Services/LanguageFeatureProvider.swift @@ -0,0 +1,369 @@ +import Foundation + +enum LanguageFeature: Hashable, Sendable { + case completion + case hover + case navigation(method: String) +} + +struct LanguageFeatureProviderPriority: RawRepresentable, Comparable, Hashable, Sendable { + let rawValue: Int + + init(rawValue: Int) { + self.rawValue = rawValue + } + + static let builtin = Self(rawValue: 0) + static let projectSymbols = Self(rawValue: 100) + static let languageServer = Self(rawValue: 200) + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct LanguageFeatureRequestContext: Sendable { + let fileURL: URL + let text: String + let position: LanguageServerPosition + let languageID: String? + let workspaceURL: URL? + + init( + fileURL: URL, + text: String, + position: LanguageServerPosition, + languageID: String? = nil, + workspaceURL: URL? = nil + ) { + self.fileURL = fileURL.standardizedFileURL + self.text = text + self.position = position + self.languageID = languageID + self.workspaceURL = workspaceURL?.standardizedFileURL + } +} + +@MainActor +protocol LanguageFeatureProvider: AnyObject { + var id: String { get } + var priority: LanguageFeatureProviderPriority { get } + + func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool + func completions( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws + func hover( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws + func navigate( + method: String, + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws +} + +@MainActor +final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { + let id = "builtin" + let priority: LanguageFeatureProviderPriority = .builtin + + private let core: RustCoreBridge + + init(core: RustCoreBridge = RustCoreBridge()) { + self.core = core + } + + func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool { + switch feature { + case .completion: + return core.isAvailable || Self.keywordLanguage(for: context) != nil + case .hover, .navigation: + return core.isAvailable + } + } + + func completions( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + let symbols = core.isAvailable + ? core.builtinLanguageCompletions( + fileURL: context.fileURL, + text: context.text, + position: context.position + ) ?? [] + : [] + let keywords = Self.keywordCompletions(in: context) + + var seenLabels = Set() + let merged = (symbols + keywords).filter { seenLabels.insert($0.label).inserted } + completion(.success(merged)) + } + + func hover( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + completion(.success( + core.isAvailable + ? core.builtinLanguageHover( + fileURL: context.fileURL, + text: context.text, + position: context.position + ) + : nil + )) + } + + func navigate( + method: String, + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + completion(.success( + core.isAvailable + ? core.builtinLanguageNavigation( + method: method, + fileURL: context.fileURL, + text: context.text, + position: context.position + ) ?? [] + : [] + )) + } +} + +@MainActor +final class LanguageServerFeatureProvider: LanguageFeatureProvider { + let id: String + let priority: LanguageFeatureProviderPriority = .languageServer + + private let session: any LanguageServerSession + private(set) var features: LanguageServerFeatureSet + + init( + providerID: String, + session: any LanguageServerSession, + features: LanguageServerFeatureSet = [] + ) { + id = "lsp:\(providerID)" + self.session = session + self.features = features + } + + func updateFeatures(_ features: LanguageServerFeatureSet) { + self.features = features + } + + func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { + guard session.isRunning else { return false } + switch feature { + case .completion: + return features.contains(.completion) + case .hover: + return features.contains(.hover) + case .navigation(let method): + return features.contains(Self.navigationFeature(for: method)) + } + } + + func completions( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + try session.completions( + fileURL: context.fileURL, + position: context.position, + completion: completion + ) + } + + func hover( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + try session.hover( + fileURL: context.fileURL, + position: context.position, + completion: completion + ) + } + + func navigate( + method: String, + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + try session.navigate( + method: method, + fileURL: context.fileURL, + position: context.position, + completion: completion + ) + } + + private static func navigationFeature(for method: String) -> LanguageServerFeatureSet { + switch method { + case "textDocument/references": .references + case "textDocument/implementation": .implementation + default: .definition + } + } +} + +private extension BuiltinLanguageFeatureProvider { + enum KeywordLanguage { + case go + case swift + case rust + case python + case javaScript + case typeScript + + var displayName: String { + switch self { + case .go: "Go" + case .swift: "Swift" + case .rust: "Rust" + case .python: "Python" + case .javaScript: "JavaScript" + case .typeScript: "TypeScript" + } + } + + var keywords: Set { + switch self { + case .go: + return [ + "break", "case", "chan", "const", "continue", "default", "defer", "else", + "fallthrough", "for", "func", "go", "goto", "if", "import", "interface", + "map", "package", "range", "return", "select", "struct", "switch", "type", "var" + ] + case .swift: + return [ + "actor", "any", "as", "associatedtype", "async", "await", "break", "case", + "catch", "class", "continue", "convenience", "default", "defer", "deinit", "didSet", + "distributed", "do", "dynamic", "else", "enum", "extension", "fallthrough", "fileprivate", + "final", "for", "func", "get", "guard", "if", "import", "in", "indirect", "infix", + "init", "inout", "internal", "is", "isolated", "lazy", "let", "macro", "mutating", + "nonisolated", "nonmutating", "open", "operator", "optional", "override", "package", + "postfix", "precedencegroup", "prefix", "private", "protocol", "public", "repeat", "required", + "rethrows", "return", "set", "some", "static", "struct", "subscript", "super", "switch", + "throws", "try", "typealias", "unowned", "var", "weak", "where", "while", "willSet" + ] + case .rust: + return [ + "Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", + "crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", + "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", + "priv", "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true", + "try", "type", "typeof", "union", "unsafe", "unsized", "use", "virtual", "where", "while", "yield" + ] + case .python: + return [ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "case", + "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", + "global", "if", "import", "in", "is", "lambda", "match", "nonlocal", "not", "or", "pass", + "raise", "return", "try", "while", "with", "yield" + ] + case .javaScript: + return Self.javaScriptKeywords + case .typeScript: + return Self.javaScriptKeywords.union([ + "abstract", "any", "as", "asserts", "bigint", "boolean", "constructor", "declare", "enum", + "from", "get", "implements", "infer", "interface", "is", "keyof", "module", "namespace", + "never", "number", "object", "override", "private", "protected", "public", "readonly", "require", + "set", "string", "symbol", "type", "undefined", "unique", "unknown" + ]) + } + } + + private static let javaScriptKeywords: Set = [ + "async", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", + "delete", "do", "else", "export", "extends", "false", "finally", "for", "function", "if", "import", + "in", "instanceof", "let", "new", "null", "of", "return", "static", "super", "switch", "this", + "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield" + ] + } + + static func keywordLanguage(for context: LanguageFeatureRequestContext) -> KeywordLanguage? { + if let languageID = context.languageID?.lowercased() { + switch languageID { + case "go", "golang": return .go + case "swift": return .swift + case "rust": return .rust + case "python": return .python + case "javascript", "javascriptreact", "jsx": return .javaScript + case "typescript", "typescriptreact", "tsx": return .typeScript + default: break + } + } + + switch context.fileURL.pathExtension.lowercased() { + case "go": return .go + case "swift": return .swift + case "rs": return .rust + case "py", "pyw": return .python + case "js", "jsx", "mjs", "cjs": return .javaScript + case "ts", "tsx", "mts", "cts": return .typeScript + default: return nil + } + } + + static func keywordCompletions( + in context: LanguageFeatureRequestContext + ) -> [LanguageServerCompletionItem] { + guard let language = keywordLanguage(for: context) else { return [] } + let prefix = identifierPrefix(in: context.text, at: context.position) + let startColumn = max(0, context.position.utf16Column - prefix.utf16.count) + let editRange = LanguageServerRange( + start: LanguageServerPosition(line: context.position.line, utf16Column: startColumn), + end: context.position + ) + + return language.keywords + .filter { prefix.isEmpty || $0.hasPrefix(prefix) } + .sorted() + .map { keyword in + LanguageServerCompletionItem( + label: keyword, + detail: "\(language.displayName) keyword", + documentation: nil, + insertText: keyword, + sortText: "zz_keyword_\(keyword)", + filterText: keyword, + kind: 14, + textEdit: LanguageServerTextEdit(range: editRange, newText: keyword), + additionalTextEdits: [], + data: nil + ) + } + } + + static func identifierPrefix(in text: String, at position: LanguageServerPosition) -> String { + guard position.line >= 0, position.utf16Column >= 0 else { return "" } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + guard position.line < lines.count else { return "" } + + let line = lines[position.line] + let utf16 = line.utf16 + guard position.utf16Column <= utf16.count else { return "" } + let utf16Index = utf16.index(utf16.startIndex, offsetBy: position.utf16Column) + guard let cursor = String.Index(utf16Index, within: line) else { return "" } + + var start = cursor + while start > line.startIndex { + let previous = line.index(before: start) + guard isIdentifierCharacter(line[previous]) else { break } + start = previous + } + return String(line[start.. Bool { + character == "_" || character == "$" || character.isLetter || character.isNumber + } +} diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index dd3d66a2..7b2b4a87 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -20,9 +20,8 @@ enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { } } -/// UI-facing façade for language tooling. LSP behavior is intentionally not -/// implemented in Swift; these entry points are stable while the Rust LSP host -/// is wired underneath them. +/// UI-facing façade that routes language features across active LSP sessions +/// and lightweight local providers without exposing either implementation. @MainActor final class LanguageToolingSessionManager: ObservableObject { @Published private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] @@ -35,10 +34,11 @@ final class LanguageToolingSessionManager: ObservableObject { var onDebugEvent: ((String, DebugAdapterEvent) -> Void)? private var catalog: LanguageProviderCatalog - private let core: RustCoreBridge private var runtimesByID: [String: any LanguageProviderRuntime] private var languageServers: [String: any LanguageServerSession] = [:] private var languageServerRoots: [String: URL] = [:] + private var languageFeatureProviders: [any LanguageFeatureProvider] + private var languageServerFeatureProviders: [String: LanguageServerFeatureProvider] = [:] private var debugAdapters: [String: any DebugAdapterSession] = [:] private var debugAdapterRoots: [String: URL] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] @@ -46,10 +46,13 @@ final class LanguageToolingSessionManager: ObservableObject { init( catalog: LanguageProviderCatalog = .standard, runtimes: [any LanguageProviderRuntime] = [], - core: RustCoreBridge = RustCoreBridge() + core: RustCoreBridge = RustCoreBridge(), + languageFeatureProviders: [any LanguageFeatureProvider] = [] ) { self.catalog = catalog - self.core = core + self.languageFeatureProviders = languageFeatureProviders + [ + BuiltinLanguageFeatureProvider(core: core) + ] runtimesByID = Dictionary(uniqueKeysWithValues: runtimes.map { ($0.descriptor.id, $0) }) } @@ -75,9 +78,6 @@ final class LanguageToolingSessionManager: ObservableObject { } func supportsGenericEditing(for fileURL: URL) -> Bool { - guard catalog.provider(for: fileURL)?.capabilities.contains(.languageServer) == true else { - return false - } return !features(for: fileURL).isEmpty } @@ -88,10 +88,29 @@ final class LanguageToolingSessionManager: ObservableObject { } func features(for fileURL: URL) -> LanguageServerFeatureSet { - guard let descriptor = catalog.provider(for: fileURL) else { return [] } - if let features = languageServerFeatures[descriptor.id] { return features } - guard descriptor.capabilities.contains(.languageServer), core.isAvailable else { return [] } - return [.definition, .references, .implementation, .hover, .completion] + let context = featureContext( + fileURL: fileURL, + text: "", + position: LanguageServerPosition(line: 0, utf16Column: 0), + rootURL: nil + ) + var result = catalog.provider(for: fileURL).flatMap { + languageServerFeatures[$0.id] + } ?? [] + for provider in languageFeatureProviders { + if provider.supports(.completion, in: context) { result.insert(.completion) } + if provider.supports(.hover, in: context) { result.insert(.hover) } + if provider.supports(.navigation(method: "textDocument/definition"), in: context) { + result.insert(.definition) + } + if provider.supports(.navigation(method: "textDocument/references"), in: context) { + result.insert(.references) + } + if provider.supports(.navigation(method: "textDocument/implementation"), in: context) { + result.insert(.implementation) + } + } + return result } func synchronizeLanguageServer( @@ -115,13 +134,25 @@ final class LanguageToolingSessionManager: ObservableObject { session = active } else { languageServers[descriptor.id]?.stop() + languageServerFeatureProviders[descriptor.id] = nil guard let created = runtime.makeLanguageServerSession() else { throw LanguageToolingSessionError.toolingUnavailable( runtime.unavailableToolingMessage ?? descriptor.displayName ) } + let featureProvider = LanguageServerFeatureProvider( + providerID: descriptor.id, + session: created, + features: created.features + ) + languageServerFeatureProviders[descriptor.id] = featureProvider configureLanguageServerCallbacks(created, providerID: descriptor.id) - try created.start(rootURL: normalizedRoot) + do { + try created.start(rootURL: normalizedRoot) + } catch { + languageServerFeatureProviders[descriptor.id] = nil + throw error + } languageServers[descriptor.id] = created languageServerRoots[descriptor.id] = normalizedRoot session = created @@ -134,7 +165,9 @@ final class LanguageToolingSessionManager: ObservableObject { } func closeDocument(_ fileURL: URL) { - diagnostics[fileURL.standardizedFileURL] = nil + let standardizedURL = fileURL.standardizedFileURL + diagnostics[standardizedURL] = nil + languageServerSession(for: standardizedURL)?.closeDocument(standardizedURL) } func clearDiagnostics() { @@ -145,6 +178,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServers.removeValue(forKey: providerID)?.stop() languageServerRoots[providerID] = nil languageServerFeatures[providerID] = nil + languageServerFeatureProviders[providerID] = nil } func stopAllLanguageServers() { @@ -153,6 +187,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerFeatures = [:] languageServers.removeAll() languageServerRoots.removeAll() + languageServerFeatureProviders.removeAll() } func navigate( @@ -160,78 +195,81 @@ final class LanguageToolingSessionManager: ObservableObject { fileURL: URL, text: String, position: LanguageServerPosition, - rootURL _: URL, + rootURL: URL, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.navigate( - method: method, - fileURL: fileURL, - position: position, - completion: completion - ) - return - } catch {} - } - guard supportsBuiltinLanguageServer(for: fileURL) else { + let context = featureContext( + fileURL: fileURL, + text: text, + position: position, + rootURL: rootURL + ) + let providers = featureProviders( + for: .navigation(method: method), + context: context + ) + guard !providers.isEmpty else { throw unavailableLanguageServerError(for: fileURL) } - completion(.success(core.builtinLanguageNavigation( + routeNavigation( + providers: providers, + index: 0, method: method, - fileURL: fileURL, - text: text, - position: position - ) ?? [])) + context: context, + completion: completion + ) } func hover( fileURL: URL, text: String, position: LanguageServerPosition, - rootURL _: URL, + rootURL: URL, completion: @escaping (Result) -> Void ) throws { - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.hover(fileURL: fileURL, position: position, completion: completion) - return - } catch {} - } - guard supportsBuiltinLanguageServer(for: fileURL) else { - throw unavailableLanguageServerError(for: fileURL) - } - completion(.success(core.builtinLanguageHover( + let context = featureContext( fileURL: fileURL, text: text, - position: position - ))) + position: position, + rootURL: rootURL + ) + let providers = featureProviders(for: .hover, context: context) + guard !providers.isEmpty else { + throw unavailableLanguageServerError(for: fileURL) + } + routeHover( + providers: providers, + index: 0, + context: context, + completion: completion + ) } func completions( fileURL: URL, text: String, position: LanguageServerPosition, - rootURL _: URL, + rootURL: URL, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.completions(fileURL: fileURL, position: position, completion: completion) - return - } catch {} - } - guard supportsBuiltinLanguageServer(for: fileURL) else { - throw unavailableLanguageServerError(for: fileURL) - } - completion(.success(core.builtinLanguageCompletions( + let context = featureContext( fileURL: fileURL, text: text, - position: position - ) ?? [])) + position: position, + rootURL: rootURL + ) + let providers = featureProviders(for: .completion, context: context) + guard !providers.isEmpty else { + throw unavailableLanguageServerError(for: fileURL) + } + routeCompletions( + providers: providers, + index: 0, + context: context, + items: [], + seenLabels: [], + completion: completion + ) } func rename( @@ -426,6 +464,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerFeatures = [:] languageServers.removeAll() languageServerRoots.removeAll() + languageServerFeatureProviders.removeAll() debugAdapters.removeAll() debugAdapterRoots.removeAll() debugStates = [:] @@ -485,23 +524,164 @@ final class LanguageToolingSessionManager: ObservableObject { ) } - private func supportsBuiltinLanguageServer(for fileURL: URL) -> Bool { - catalog.provider(for: fileURL)?.capabilities.contains(.languageServer) == true - && core.isAvailable - } - private func languageServerSession(for fileURL: URL) -> (any LanguageServerSession)? { guard let descriptor = catalog.provider(for: fileURL) else { return nil } return languageServers[descriptor.id] } + private func featureContext( + fileURL: URL, + text: String, + position: LanguageServerPosition, + rootURL: URL? + ) -> LanguageFeatureRequestContext { + let descriptor = catalog.provider(for: fileURL) + return LanguageFeatureRequestContext( + fileURL: fileURL, + text: text, + position: position, + languageID: descriptor?.languageIdentifier(for: fileURL), + workspaceURL: rootURL + ) + } + + private func featureProviders( + for feature: LanguageFeature, + context: LanguageFeatureRequestContext + ) -> [any LanguageFeatureProvider] { + var providers = languageFeatureProviders + if let descriptor = catalog.provider(for: context.fileURL), + let languageServerProvider = languageServerFeatureProviders[descriptor.id] { + providers.append(languageServerProvider) + } + return providers + .filter { $0.supports(feature, in: context) } + .sorted { $0.priority > $1.priority } + } + + private func routeCompletions( + providers: [any LanguageFeatureProvider], + index: Int, + context: LanguageFeatureRequestContext, + items: [LanguageServerCompletionItem], + seenLabels: Set, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) { + guard index < providers.count else { + completion(.success(items)) + return + } + do { + try providers[index].completions(in: context) { [self] result in + var merged = items + var labels = seenLabels + if case .success(let providerItems) = result { + for item in providerItems where labels.insert(item.label).inserted { + merged.append(item) + } + } + routeCompletions( + providers: providers, + index: index + 1, + context: context, + items: merged, + seenLabels: labels, + completion: completion + ) + } + } catch { + routeCompletions( + providers: providers, + index: index + 1, + context: context, + items: items, + seenLabels: seenLabels, + completion: completion + ) + } + } + + private func routeHover( + providers: [any LanguageFeatureProvider], + index: Int, + context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) { + guard index < providers.count else { + completion(.success(nil)) + return + } + do { + try providers[index].hover(in: context) { [self] result in + if case .success(let hover?) = result { + completion(.success(hover)) + } else { + routeHover( + providers: providers, + index: index + 1, + context: context, + completion: completion + ) + } + } + } catch { + routeHover( + providers: providers, + index: index + 1, + context: context, + completion: completion + ) + } + } + + private func routeNavigation( + providers: [any LanguageFeatureProvider], + index: Int, + method: String, + context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) { + guard index < providers.count else { + completion(.success([])) + return + } + do { + try providers[index].navigate(method: method, in: context) { [self] result in + if case .success(let locations) = result, !locations.isEmpty { + completion(.success(locations)) + } else { + routeNavigation( + providers: providers, + index: index + 1, + method: method, + context: context, + completion: completion + ) + } + } + } catch { + routeNavigation( + providers: providers, + index: index + 1, + method: method, + context: context, + completion: completion + ) + } + } + private func configureLanguageServerCallbacks( _ session: any LanguageServerSession, - providerID _: String + providerID: String ) { session.onDiagnostics = { [weak self] fileURL, diagnostics in self?.diagnostics[fileURL.standardizedFileURL] = diagnostics } + session.onFeaturesChange = { [weak self] features in + guard let self else { return } + self.languageServerFeatures[providerID] = features + self.languageServerFeatureProviders[providerID]?.updateFeatures(features) + } } private func configureDebugCallbacks( diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 2b1693db..cd902743 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -13,6 +13,13 @@ protocol LspClientCore: Sendable { fileURL: URL, text: String ) -> RustCoreBridge.LspClientResponsePayload? + func lspClientCloseDocument( + state: ToolingJSONValue, + fileURL: URL + ) -> RustCoreBridge.LspClientResponsePayload? + func lspClientShutdown( + state: ToolingJSONValue + ) -> RustCoreBridge.LspClientResponsePayload? func lspClientRequest( state: ToolingJSONValue, fileURL: URL, @@ -38,6 +45,17 @@ protocol LspClientCore: Sendable { extension RustCoreBridge: LspClientCore {} +extension LspClientCore { + func lspClientCloseDocument( + state _: ToolingJSONValue, + fileURL _: URL + ) -> RustCoreBridge.LspClientResponsePayload? { nil } + + func lspClientShutdown( + state _: ToolingJSONValue + ) -> RustCoreBridge.LspClientResponsePayload? { nil } +} + @MainActor final class StdioLanguageServerSession: LanguageServerSession { private let executableURL: URL @@ -51,8 +69,12 @@ final class StdioLanguageServerSession: LanguageServerSession { private var pendingDocuments: [String: PendingDocument] = [:] private var responseHandlers: [String: (RustCoreBridge.LspClientEventPayload) -> Void] = [:] private var isInitialized = false + private var isStopping = false + private var shutdownFallbackTask: Task? var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? + private(set) var features: LanguageServerFeatureSet = [] + var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? init( executableURL: URL, @@ -116,6 +138,19 @@ final class StdioLanguageServerSession: LanguageServerSession { if let response { apply(response) } } + func closeDocument(_ fileURL: URL) { + let standardizedURL = fileURL.standardizedFileURL + let uri = standardizedURL.absoluteString + pendingDocuments[uri] = nil + guard openedDocumentURIs.remove(uri) != nil, + let state, + let response = core.lspClientCloseDocument( + state: state, + fileURL: standardizedURL + ) else { return } + apply(response) + } + func completions( fileURL: URL, position: LanguageServerPosition, @@ -259,12 +294,31 @@ final class StdioLanguageServerSession: LanguageServerSession { } func stop() { - process.stop() - resetTransientState() + guard process.isRunning else { + resetTransientState() + return + } + guard !isStopping, + isInitialized, + let state, + let response = core.lspClientShutdown(state: state) else { + forceStop() + return + } + isStopping = true + apply(response) + shutdownFallbackTask?.cancel() + // The task intentionally retains the session after its manager removes it. + shutdownFallbackTask = Task { @MainActor [self] in + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled else { return } + forceStop() + } } private func apply(_ response: RustCoreBridge.LspClientResponsePayload) { state = response.state + updateFeatures(from: response.state) response.messages.forEach(sendRawJSON) handle(response.events) } @@ -279,6 +333,10 @@ final class StdioLanguageServerSession: LanguageServerSession { isInitialized = true flushPendingDocuments() } + if event.method == "shutdown" { + forceStop() + return + } if event.kind == "diagnostics", let uri = event.uri, let url = URL(string: uri), @@ -369,12 +427,54 @@ final class StdioLanguageServerSession: LanguageServerSession { } private func resetTransientState() { + shutdownFallbackTask?.cancel() + shutdownFallbackTask = nil state = nil readBuffer = Data() openedDocumentURIs = [] pendingDocuments = [:] responseHandlers = [:] isInitialized = false + isStopping = false + if !features.isEmpty { + features = [] + onFeaturesChange?([]) + } + } + + private func forceStop() { + shutdownFallbackTask?.cancel() + shutdownFallbackTask = nil + process.stop() + resetTransientState() + } + + private func updateFeatures(from state: ToolingJSONValue) { + guard case .object(let object) = state, + case .array(let capabilityValues)? = object["serverCapabilities"] else { return } + let names = capabilityValues.compactMap { value -> String? in + guard case .string(let name) = value else { return nil } + return name + } + let updated = names.reduce(into: LanguageServerFeatureSet()) { result, name in + switch name { + case "definition": result.insert(.definition) + case "references": result.insert(.references) + case "implementation": result.insert(.implementation) + case "hover": result.insert(.hover) + case "completion": result.insert(.completion) + case "rename": result.insert(.rename) + case "formatting": result.insert(.formatting) + case "codeActions": result.insert(.codeActions) + case "completionResolve": result.insert(.completionResolve) + case "codeActionResolve": result.insert(.codeActionResolve) + case "executeCommand": result.insert(.executeCommand) + default: break + } + } + guard updated != features else { return } + features = updated + onFeaturesChange?(updated) } private static func requestID(from message: String) -> String? { diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift new file mode 100644 index 00000000..414f338c --- /dev/null +++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Language feature providers") +@MainActor +struct LanguageFeatureProviderTests { + @Test + func builtinCompletionIncludesLanguageKeywords() throws { + let provider = BuiltinLanguageFeatureProvider() + let context = LanguageFeatureRequestContext( + fileURL: URL(fileURLWithPath: "/tmp/main.go"), + text: "fu", + position: LanguageServerPosition(line: 0, utf16Column: 2), + languageID: "go" + ) + var result: Result<[LanguageServerCompletionItem], Error>? + + try provider.completions(in: context) { result = $0 } + + let resolved = try #require(result) + let items = try resolved.get() + let item = try #require(items.first { $0.label == "func" }) + #expect(item.detail == "Go keyword") + #expect(item.textEdit?.range.start.utf16Column == 0) + #expect(item.textEdit?.range.end.utf16Column == 2) + } + + @Test + func managerMergesHigherPriorityProviderWithBuiltinFallback() throws { + let remote = CompletionFeatureProvider(items: [ + Self.item(label: "format", detail: "LSP"), + Self.item(label: "func", detail: "LSP") + ]) + let manager = LanguageToolingSessionManager( + languageFeatureProviders: [remote] + ) + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + var result: Result<[LanguageServerCompletionItem], Error>? + + try manager.completions( + fileURL: fileURL, + text: "f", + position: LanguageServerPosition(line: 0, utf16Column: 1), + rootURL: fileURL.deletingLastPathComponent() + ) { result = $0 } + + let resolved = try #require(result) + let items = try resolved.get() + #expect(items.first?.label == "format") + #expect(items.filter { $0.label == "func" }.count == 1) + #expect(items.contains { $0.label == "for" && $0.detail == "Go keyword" }) + } + + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { + LanguageServerCompletionItem( + label: label, + detail: detail, + documentation: nil, + insertText: label, + sortText: nil, + filterText: nil, + kind: nil, + textEdit: nil, + additionalTextEdits: [], + data: nil + ) + } +} + +@MainActor +private final class CompletionFeatureProvider: LanguageFeatureProvider { + let id = "test.remote" + let priority: LanguageFeatureProviderPriority = .languageServer + private let items: [LanguageServerCompletionItem] + + init(items: [LanguageServerCompletionItem]) { + self.items = items + } + + func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { + feature == .completion + } + + func completions( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + completion(.success(items)) + } + + func hover( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + completion(.success(nil)) + } + + func navigate( + method _: String, + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + completion(.success([])) + } +} diff --git a/Tests/LitheTests/RealGoplsIntegrationTests.swift b/Tests/LitheTests/RealGoplsIntegrationTests.swift new file mode 100644 index 00000000..56311101 --- /dev/null +++ b/Tests/LitheTests/RealGoplsIntegrationTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Real gopls integration") +@MainActor +struct RealGoplsIntegrationTests { + @Test + func goplsRunsThroughLanguageToolingSessionManager() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["LITHE_RUN_GOPLS_INTEGRATION"] == "1" else { return } + + let goplsURL = URL(fileURLWithPath: environment["LITHE_GOPLS_PATH"] + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".go/bin/gopls").path) + #expect(FileManager.default.isExecutableFile(atPath: goplsURL.path)) + guard FileManager.default.isExecutableFile(atPath: goplsURL.path) else { return } + + let core = RustCoreBridge() + #expect(core.isAvailable) + guard core.isAvailable else { return } + + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-real-gopls-\(UUID().uuidString)", isDirectory: true) + let sourceURL = rootURL.appendingPathComponent("main.go") + let source = """ + package main + + import "fmt" + + func main() { + fmt.Pr + } + + """ + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + try "module example.com/lithegopls\n\ngo 1.24\n".write( + to: rootURL.appendingPathComponent("go.mod"), + atomically: true, + encoding: .utf8 + ) + try source.write(to: sourceURL, atomically: true, encoding: .utf8) + + let descriptor = LanguageProviderDescriptor( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "go" + ) + let session = StdioLanguageServerSession( + executableURL: goplsURL, + arguments: [], + environment: environment, + process: MacRawProcessSession(), + core: core + ) + let runtime = RealGoplsLanguageRuntime(descriptor: descriptor, session: session) + let manager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimes: [runtime], + core: core + ) + defer { + manager.stopAll() + try? FileManager.default.removeItem(at: rootURL) + } + + try manager.synchronizeLanguageServer( + for: sourceURL, + text: source, + rootURL: rootURL + ) + let initialized = await waitUntil { + manager.languageServerFeatures["go"]?.contains(.completion) == true + && manager.languageServerFeatures["go"]?.contains(.hover) == true + } + #expect(initialized) + guard initialized else { return } + + var completionResult: Result<[LanguageServerCompletionItem], Error>? + try manager.completions( + fileURL: sourceURL, + text: source, + position: LanguageServerPosition(line: 5, utf16Column: 10), + rootURL: rootURL + ) { completionResult = $0 } + let completed = await waitUntil { completionResult != nil } + #expect(completed) + let resolvedCompletions = try #require(completionResult) + let completionItems = try resolvedCompletions.get() + #expect(!completionItems.isEmpty) + #expect(completionItems.contains { $0.label.lowercased().contains("print") }) + + var hoverResult: Result? + try manager.hover( + fileURL: sourceURL, + text: source, + position: LanguageServerPosition(line: 5, utf16Column: 5), + rootURL: rootURL + ) { hoverResult = $0 } + let hovered = await waitUntil { hoverResult != nil } + #expect(hovered) + let resolvedHover = try #require(hoverResult) + let hover = try resolvedHover.get() + #expect(hover?.contents.isEmpty == false) + + manager.closeDocument(sourceURL) + try manager.synchronizeLanguageServer( + for: sourceURL, + text: source, + rootURL: rootURL + ) + manager.stopLanguageServer(providerID: "go") + let stopped = await waitUntil { !session.isRunning } + #expect(stopped) + } + + private func waitUntil( + timeout: TimeInterval = 15, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return condition() + } +} + +@MainActor +private final class RealGoplsLanguageRuntime: LanguageProviderRuntime { + let descriptor: LanguageProviderDescriptor + let supportsLanguageServerSession = true + private let session: any LanguageServerSession + + init(descriptor: LanguageProviderDescriptor, session: any LanguageServerSession) { + self.descriptor = descriptor + self.session = session + } + + func makeLanguageServerSession() -> (any LanguageServerSession)? { session } + func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil } +} diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index bc5945cf..ada76ddf 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -267,6 +267,23 @@ struct RunConfigurationIntegrationTests { .recovery.contains("LITHE_JAVA_DEBUG_PATH")) } + @Test + func macToolDiscoveryFindsGoLanguageServerInUserBin() { + let discovery = MacRuntimeToolDiscovery( + homeDirectoryURL: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + isExecutable: { $0.path == "/tmp/home/.go/bin/gopls" } + ) + + let candidates = discovery.candidates( + for: "gopls", + projectURL: nil, + environment: ["PATH": "/usr/bin"] + ) + + #expect(candidates.first?.executableURL.path == "/tmp/home/.go/bin/gopls") + #expect(candidates.first?.source == .environment) + } + @Test func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws { let source = URL(fileURLWithPath: "/tmp/Main.java") @@ -965,8 +982,8 @@ struct RunConfigurationIntegrationTests { let source = root.appendingPathComponent("main.go") #expect(manager.activeLanguageServerIDs.isEmpty) - #expect(manager.features(for: source).isEmpty) - #expect(!manager.supportsGenericEditing(for: source)) + #expect(manager.features(for: source).contains(.completion)) + #expect(manager.supportsGenericEditing(for: source)) try manager.synchronizeLanguageServer( for: source, text: "package main\nfunc main() {}\n", @@ -975,11 +992,22 @@ struct RunConfigurationIntegrationTests { #expect(process.requests.isEmpty) #expect(process.sentData.isEmpty) + var completionResult: Result<[LanguageServerCompletionItem], Error>? + try manager.completions( + fileURL: source, + text: "fu", + position: LanguageServerPosition(line: 0, utf16Column: 2), + rootURL: root + ) { result in + completionResult = result + } + #expect(try completionResult?.get().contains { $0.label == "func" } == true) + #expect(throws: LanguageToolingSessionError.self) { try manager.hover( fileURL: source, text: "package main\n", - position: LanguageServerPosition(line: 0, utf16Column: 0), + position: LanguageServerPosition(line: 0, utf16Column: 1), rootURL: root ) { _ in } } @@ -1048,6 +1076,8 @@ struct RunConfigurationIntegrationTests { ] ]) await Self.drainMainActorTasks() + #expect(manager.languageServerFeatures["swift"]?.contains(.completion) == true) + #expect(manager.languageServerFeatures["swift"]?.contains(.hover) == true) let framedOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() #expect(framedOutput.contains("\"method\":\"initialized\"")) @@ -1286,6 +1316,22 @@ struct RunConfigurationIntegrationTests { await Self.drainMainActorTasks() #expect(executeResult != nil) try executeResult?.get() + + manager.closeDocument(source) + #expect(process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() + .contains("\"method\":\"textDocument/didClose\"")) + manager.stopLanguageServer(providerID: "swift") + #expect(process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() + .contains("\"method\":\"shutdown\"")) + process.emitJSON([ + "jsonrpc": "2.0", + "id": "9", + "result": NSNull() + ]) + await Self.drainMainActorTasks() + let shutdownOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() + #expect(shutdownOutput.contains("\"method\":\"exit\"")) + #expect(!process.isRunning) } @Test @@ -2953,6 +2999,27 @@ private struct TestLspClientCore: LspClientCore { response() } + func lspClientCloseDocument( + state: ToolingJSONValue, + fileURL: URL + ) -> RustCoreBridge.LspClientResponsePayload? { + response( + state: state, + messages: [ + #"{"jsonrpc":"2.0","method":"textDocument/didClose","params":{"textDocument":{"uri":"\#(fileURL.standardizedFileURL.absoluteString)"}}}"# + ] + ) + } + + func lspClientShutdown( + state: ToolingJSONValue + ) -> RustCoreBridge.LspClientResponsePayload? { + response( + state: state, + messages: [#"{"jsonrpc":"2.0","id":"9","method":"shutdown"}"#] + ) + } + func lspClientRequest( state _: ToolingJSONValue, fileURL _: URL, @@ -3185,7 +3252,36 @@ private struct TestLspClientCore: LspClientCore { ) ]) } + if message.contains(#""id":"9""#) { + return response( + state: .object([:]), + messages: [#"{"jsonrpc":"2.0","method":"exit"}"#], + events: [ + RustCoreBridge.LspClientEventPayload( + kind: "response", + requestId: "9", + method: "shutdown", + uri: nil, + diagnostics: nil, + result: .object(["ok": .bool(true)]), + error: nil + ) + ] + ) + } return response( + state: .object([ + "serverCapabilities": .array([ + .string("hover"), + .string("completion"), + .string("completionResolve"), + .string("rename"), + .string("formatting"), + .string("codeActions"), + .string("codeActionResolve"), + .string("executeCommand") + ]) + ]), messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], events: [ RustCoreBridge.LspClientEventPayload( @@ -3237,11 +3333,12 @@ private struct TestLspClientCore: LspClientCore { } private func response( + state: ToolingJSONValue = .object([:]), messages: [String] = [], events: [RustCoreBridge.LspClientEventPayload] = [] ) -> RustCoreBridge.LspClientResponsePayload { RustCoreBridge.LspClientResponsePayload( - state: .object([:]), + state: state, messages: messages, events: events ) diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md new file mode 100644 index 00000000..716b9149 --- /dev/null +++ b/docs/architecture/language-tooling.md @@ -0,0 +1,149 @@ +# 语言工具与 LSP 架构 + +本文说明 Lithe 当前的语言能力分层、LSP 兼容边界,以及接入新语言服务器时必须遵守的约束。公开的 Rust JSON 命令仍以 +[`rust-core-api.md`](../../shared/contracts/rust-core-api.md) 为准。 + +## 设计目标 + +语言能力不应等同于“已经启动一个 LSP 进程”。当前实现遵循以下规则: + +1. 编辑器只依赖统一的 `LanguageFeatureProvider`,不直接依赖具体语言服务器。 +2. 轻量本地能力无需外部进程,LSP 是按需启动的语义增强层。 +3. 可调用的 LSP 功能以服务器 `initialize` 响应和动态注册结果为准,不能根据语言名称硬编码。 +4. JSON-RPC 状态机和结果归一化属于 Rust Core;进程、stdio 和可执行文件发现属于平台 adapter。 +5. 单个 provider 失败、缺失或返回空结果时,不应阻断仍可工作的本地能力。 + +## 组件边界 + +```mermaid +flowchart LR + UI["Editor / feature model"] --> MANAGER["LanguageToolingSessionManager"] + MANAGER --> ROUTER["LanguageFeatureProvider routing"] + ROUTER --> BUILTIN["Builtin provider
keywords + current-file symbols"] + ROUTER --> LSPPROVIDER["LSP provider
server capabilities"] + LSPPROVIDER --> SESSION["StdioLanguageServerSession"] + SESSION --> CORE["Rust LSP client core
state + JSON-RPC + normalization"] + SESSION --> PROCESS["RawProcessSession
stdio transport"] + PROCESS --> SERVER["gopls / jdtls / rust-analyzer / ..."] +``` + +| 层 | 职责 | 不负责 | +| --- | --- | --- | +| `LanguageToolingSessionManager` | 文档同步、provider 选择、结果降级/合并、诊断和会话归属 | JSON-RPC 编解码、直接启动 `Process` | +| `LanguageFeatureProvider` | 声明单项能力、优先级和统一结果类型 | 维护 UI 状态 | +| `BuiltinLanguageFeatureProvider` | 当前文件标识符、轻量 hover/导航、语言关键字 | 类型推断、跨文件索引 | +| `LanguageServerFeatureProvider` | 将已协商的服务器能力适配到统一 provider 接口 | 猜测服务器能力 | +| `StdioLanguageServerSession` | 串联 Rust 状态机与进程 transport,管理请求回调和生命周期 | 解析每种服务器的私有协议 | +| Rust Core | LSP state、请求 ID、frame、UTF-16 位置、结果归一化、动态能力 | 可执行文件发现、子进程和线程模型 | +| macOS adapter | 工具发现、环境变量、`Process`/`Pipe`、终止进程 | 语言功能路由和协议语义 | + +## Provider 路由 + +当前优先级由高到低为 `languageServer (200)`、预留的 `projectSymbols (100)`、`builtin (0)`。每次请求先按文件和功能过滤 provider,再按优先级路由: + +- **Completion**:依次收集所有成功结果,保持高优先级顺序,并按 `label` 去重。因此 LSP 可提供精确候选,本地关键字和当前文件符号仍能补足结果。 +- **Hover**:返回第一个非空结果;LSP 无结果或失败时继续询问本地 provider。 +- **Definition/References/Implementation**:返回第一个非空位置列表,并在 LSP 不可用时降级到当前文件文本级导航。 +- **Rename/Formatting/Code Action/Resolve/Execute Command**:目前仍是 LSP-only;未运行或未声明相应能力时应返回明确的 capability 错误。 + +provider 抛错不会让路由提前结束。这个策略用于隔离第三方语言服务器故障,但也意味着新增 provider 时必须给出稳定优先级,并避免返回伪造的“成功但无意义”结果。 + +## 无进程能力 + +Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 +`lsp.builtinNavigation` 只读取当前文件文本。Swift 层另外为 Go、Swift、Rust、Python、JavaScript 和 TypeScript 提供关键字候选;即使 Rust Core 未链接,关键字补全仍可使用。 + +这些结果是可用性降级,不是类型系统: + +- 不解析依赖,不启动构建工具,不访问网络; +- 不保证跨文件定义、重载解析或类型正确性; +- 位置统一使用零基行号和 UTF-16 列,确保可与 AppKit 和 LSP 结果合并。 + +## Catalog 与工具发现 + +内置 provider catalog 位于 +[`rust/lithe-core/resources/lsp/language-providers.json`](../../rust/lithe-core/resources/lsp/language-providers.json)。项目可以通过 `.lithe/lsp/language-providers.json` 按 `id` 覆盖内置字段、添加 provider,或使用 `disabled: true` 禁用 provider: + +```json +{ + "version": 1, + "providers": [ + { + "id": "go", + "languageServerLaunch": { + "executableNames": ["gopls-custom", "gopls"], + "arguments": [] + } + }, + { + "id": "templ", + "displayName": "Templ", + "fileExtensions": ["templ"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "templ", + "languageServerLaunch": { + "executableNames": ["templ"], + "arguments": ["lsp"] + } + } + ] +} +``` + +`executableNames` 按顺序尝试。macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 + +## LSP 会话与兼容性 + +当前 transport 是 LSP 标准的 stdio `Content-Length` framing。一个 provider 在一个 workspace root 下复用一个 session;同一 provider 切换到另一个 root 时,manager 会停止旧 session 并创建新 session。 + +启动顺序: + +1. adapter 启动进程并先安装 stdout/stderr handler,避免丢失启动阶段输出; +2. Rust Core 生成 `initialize`,记录 pending request; +3. 收到响应后,Rust Core 保存服务器 capability 并生成 `initialized`; +4. manager 发布实际 capability,随后通过 `didOpen`/全量 `didChange` 同步文档; +5. 功能请求按 request ID 回到对应 completion handler。 + +服务端 capability 可以来自 initialize 响应,也可以通过 `client/registerCapability` 和 +`client/unregisterCapability` 动态变化。当前客户端会处理 diagnostics,并对 workspace configuration、workspace folders 查询和 work-done progress 创建返回保守的空值响应;未知的服务端 request 返回 JSON-RPC `Method not found`,未知 notification 作为事件保留。 + +关闭文档时发送 `textDocument/didClose` 并清除该文档诊断。停止 session 时先请求 `shutdown`,收到响应后发送 `exit`;若服务器无响应,则由超时路径强制停止进程。不要直接以 `terminate()` 代替正常 LSP 关闭流程。 + +### 当前限制 + +- 只支持 stdio transport,尚无 socket/TCP 或服务器自定义握手 adapter。 +- session 当前以 provider ID 和单个 workspace root 为单位,尚无 multi-root session。 +- `workspace/applyEdit`、自定义初始化参数和服务器私有命令没有通用处理层;客户端不会宣称未实现的 `applyEdit` 能力。 +- 文档同步当前发送全量文本,没有按服务器类型实现增量 diff。 +- catalog 描述的是“可尝试启动的工具”;最终功能必须以运行时服务器 capability 为准。 +- project config 目前是受信任的项目配置,只接受 executable name 和参数,不执行 shell 命令。 + +## 接入新 LSP 的检查清单 + +1. 在 catalog 中定义稳定 `id`、文件匹配规则、`languageId`、候选 executable 和参数。 +2. 确认服务器支持 stdio 和标准 `Content-Length` framing。 +3. 不在 UI 或 manager 中按语言写分支;服务器差异应进入 descriptor 或独立 adapter。 +4. 用 initialize 响应验证 capability,不把 catalog 的 `languageServer` 标记当成 feature 支持证明。 +5. 至少测试 initialize、didOpen/change/close、一个功能请求、shutdown/exit 和异常退出。 +6. 包含空结果、服务器 error、UTF-16、带空格/非 ASCII 文件 URI,以及启动即输出的场景。 + +## 真实 gopls 验证 + +[`RealGoplsIntegrationTests.swift`](../../Tests/LitheTests/RealGoplsIntegrationTests.swift) +会穿过 manager、Swift session、macOS process adapter 和真实 Rust Core。测试默认不启动外部工具,需要显式开启: + +```bash +scripts/build-rust-core.sh --debug --target aarch64-apple-darwin + +LITHE_RUN_GOPLS_INTEGRATION=1 \ +LITHE_GOPLS_PATH="$HOME/.go/bin/gopls" \ +swift test --disable-sandbox --no-parallel \ + --triple arm64-apple-macosx \ + -Xswiftc -Xfrontend -Xswiftc -disable-round-trip-debug-types \ + -Xlinker -force_load \ + -Xlinker "$(pwd)/rust/target/macos/aarch64-apple-darwin/debug/liblithe_core.a" \ + --filter RealGoplsIntegrationTests +``` + +`-force_load` 是必要条件:测试包同时包含 C bridge 的 weak fallback;普通链接可能在没有加载 Rust archive 的情况下仍然成功。Intel macOS 需要把 target/triple 和库路径替换为对应的 `x86_64` 产物。 diff --git a/docs/architecture/mac-service-boundaries.md b/docs/architecture/mac-service-boundaries.md index c8587e79..ad4ad8eb 100644 --- a/docs/architecture/mac-service-boundaries.md +++ b/docs/architecture/mac-service-boundaries.md @@ -35,7 +35,7 @@ implementations. | `Sources/Lithe/Views/` | SwiftUI/AppKit presentation, input, navigation destinations, and view-local rendering. | | `Sources/Lithe/Models/` | UI-facing models and value types. `AppModel` is the observable aggregate, not the platform composition root. | | `Sources/Lithe/Application/` | Workspace, Document, Git, Search, Java, Terminal, Project History, and UI Feature Models. These coordinate state and user actions. | -| `Sources/Lithe/Services/` | Product workflow orchestration. Git, workspace, search, and history use Rust operations; Java LSP and Maven/Run/Debug process lifecycles remain Swift workflows behind ports. | +| `Sources/Lithe/Services/` | Product workflow orchestration. Language feature routing and LSP/Maven/Run/Debug lifecycles remain Swift workflows behind ports; transport-independent LSP state and normalized results live in Rust. | | `Sources/Lithe/Core/Ports/` | Platform-neutral interfaces for process, terminal, storage, runtime discovery, file operations, watchers, and native UI capabilities. | | `Sources/Lithe/Core/Rust*` | Typed operations and model conversion for the shared Rust JSON contract. | | `Sources/Lithe/Platform/MacOS/` | FSEvents, file operations, persistence, process sessions, PTY, runtime discovery, native UI, shortcuts, and updates. | @@ -48,9 +48,10 @@ or concrete `Mac*` types. They must not construct `Process`, `Pipe`, `FileManager`, `UserDefaults`, or `FileHandle` directly. Services must receive those capabilities through ports. A Service may own a -workflow state machine, such as the JDT LS protocol or Maven/Debug lifecycle, -but it must not decide how the operating system starts, watches, stores, or -terminates the underlying resource. +workflow state machine, such as language-provider routing or Maven/Debug +lifecycle, but it must not decide how the operating system starts, watches, +stores, or terminates the underlying resource. LSP JSON-RPC state and message +normalization belong to Rust Core, not a language-specific Swift service. Views receive `AppModel` or a dedicated UI Feature Model. They must not receive concrete workflow services, call the Rust C ABI directly, or construct platform @@ -64,6 +65,8 @@ The Rust Core owns deterministic cross-platform behavior: - Local History metadata and snapshot operations; - Maven descriptor and diagnostic parsing; - Java source structure, code vision, class-name, and run-configuration parsing; +- lightweight language features, LSP JSON-RPC state, framing, capabilities, + diagnostics, and normalized feature results; - request envelopes, cancellation, deadlines, error codes, validation, and stable JSON ordering. @@ -71,8 +74,8 @@ macOS owns the platform side of these capabilities: - workspace selection, FSEvents, atomic/native file operations, permissions, persistence location, and Finder integration; -- JDK/Maven/JDT LS discovery and process sessions; -- Java/Maven/Debug process transports, terminal PTY, shell, signals, and +- language-server/JDK/Maven discovery and process sessions; +- LSP/Java/Maven/Debug process transports, terminal PTY, shell, signals, and native handles; - native window, menu, clipboard, shortcut, installer, and update behavior. @@ -92,7 +95,9 @@ with `scripts/verify-shared-contracts.sh` and `scripts/verify-rust-core.sh`. ## Remaining migration work The current boundary is usable and enforced, but it is not a claim that every -workflow has moved into Rust. Java LSP protocol state, Maven execution, Java -Run/Debug sessions, and terminal session state are still Swift application -workflows using platform ports. Their data models and lifecycle events should -be promoted into shared contracts before Windows implements equivalent UI. +workflow has moved into Rust. Language provider routing, LSP process lifecycle, +Maven execution, Java Run/Debug sessions, and terminal session state are still +Swift application workflows using platform ports. Their remaining lifecycle +events should be promoted into shared contracts before Windows implements +equivalent UI. See [`language-tooling.md`](language-tooling.md) for the language +tooling split. diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index f58def82..6e154eb6 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -59,13 +59,18 @@ a fixture under `shared/fixtures/` before the second platform relies on it. | UTF-8 file command validation and results | Native file APIs, permissions, and persistence paths | | Git models, validation, parsing, and mutations | Executable environment and credentials | | History metadata and snapshot rules | History storage location and file movement | -| Maven and Java source parsing | JDK/Maven/JDT LS discovery and child processes | +| Language provider catalog, lightweight language features, LSP state, Maven, and Java source parsing | Language-server/JDK/Maven discovery and child processes | | Error codes, cancellation, deadlines, and JSON envelope | PTY/ConPTY, signals, handles, and native UI | The UI must depend on feature models and shared models, not on a concrete adapter. Core and Services must remain free of AppKit, SwiftUI, Win32, Qt, `Process`, and direct platform file APIs. +Language tooling has an additional protocol/application split: Rust owns the +transport-independent LSP state and normalized results, while platform services +own provider routing and process lifecycle. The complete rules are in +[`language-tooling.md`](language-tooling.md). + ## Repository hygiene Do not commit generated outputs such as `.build/`, `.swiftpm/`, `dist/`, diff --git "a/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md" "b/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md" index 7f705db0..344286b3 100644 --- "a/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md" +++ "b/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md" @@ -101,16 +101,18 @@ Lithe 自动发现文件与 Git 状态变化 - 基础语法着色 - 路径面包屑 -编辑器用于阅读和少量修正,不以大量人工编码为主要目标。首期仅为 Java 接入方法定义跳转和引用查找,不包含自动补全或重构系统。 +编辑器用于阅读和少量修正,不以大量人工编码为主要目标。基础补全、hover 和当前文件导航由无进程 provider 提供;安装对应语言服务器后,再按服务端实际声明的 capability 增强语义能力。安全重构仍不属于基础能力。 -### 5.5 Java 代码导航 +### 5.5 语言能力与 Java 代码导航 -- 使用 Eclipse JDT Language Server +- 语言功能统一通过 provider 路由,不让编辑器直接依赖具体 LSP +- Go、Swift、Rust、Python、JavaScript 和 TypeScript 在未启动 LSP 时仍提供关键字补全 +- Java 使用 Eclipse JDT Language Server;Go、Rust 等语言可通过 catalog 接入标准 stdio LSP - 光标处方法或符号跳转到定义 - 查找引用并在底部结果列表展示 - 点击结果打开文件并定位到精确行列 - 编辑器右键菜单与 Navigate 主菜单入口 -- 打开 Java 项目后后台启动语言服务器,关闭项目时停止 +- 语言服务器按需启动;关闭文档发送 `didClose`,停止会话执行 `shutdown`/`exit` - 接收 JDT LS `textDocument/publishDiagnostics`,显示错误、警告、信息和提示级别 - Problems 工具窗口支持按诊断严重级别筛选,并保留文件、行列、source、code 和关联信息 - 对 JDT LS 标记为 `unnecessary` 或明确未使用的代码范围进行弱化显示,同时保留可点击诊断提示 @@ -204,8 +206,8 @@ Lithe 自动发现文件与 Git 状态变化 - Windows 当前处于独立实现阶段,不纳入当前 macOS 发布版的功能承诺和验收范围。 - AI、Codex 或其他模型调用 - Agent 会话和聊天界面 -- Java 以外语言的 LSP 和代码智能 -- 传统代码补全 +- 无 LSP 时的跨文件类型感知补全和精确语义导航 +- 非 stdio transport、自定义握手和 multi-root LSP session - 自动导包、快速修复和安全重命名 - 完整代码格式化 - 测试识别、测试树和覆盖率 diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index 03497d3a..53f50f13 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -39,6 +39,8 @@ pub enum CoreCommand { LspClientInitialize, LspClientOpenDocument, LspClientChangeDocument, + LspClientCloseDocument, + LspClientShutdown, LspClientRequest, LspClientApplyServerMessage, LspFrameMessage, @@ -98,6 +100,8 @@ impl CoreCommand { "lsp.clientInitialize" => Some(Self::LspClientInitialize), "lsp.clientOpenDocument" => Some(Self::LspClientOpenDocument), "lsp.clientChangeDocument" => Some(Self::LspClientChangeDocument), + "lsp.clientCloseDocument" => Some(Self::LspClientCloseDocument), + "lsp.clientShutdown" => Some(Self::LspClientShutdown), "lsp.clientRequest" => Some(Self::LspClientRequest), "lsp.clientApplyServerMessage" => Some(Self::LspClientApplyServerMessage), "lsp.frameMessage" => Some(Self::LspFrameMessage), @@ -134,3 +138,20 @@ impl CoreCommand { } } } + +#[cfg(test)] +mod tests { + use super::CoreCommand; + + #[test] + fn parses_lsp_document_and_session_close_commands() { + assert!(matches!( + CoreCommand::parse("lsp.clientCloseDocument"), + Some(CoreCommand::LspClientCloseDocument) + )); + assert!(matches!( + CoreCommand::parse("lsp.clientShutdown"), + Some(CoreCommand::LspClientShutdown) + )); + } +} diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index ec54a016..43747f6b 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -247,6 +247,8 @@ pub struct LspClientState { #[serde(default)] pub initialized: bool, #[serde(default)] + pub shutdown_requested: bool, + #[serde(default)] pub server_capabilities: Vec, #[serde(default)] pub open_documents: BTreeMap, @@ -261,6 +263,7 @@ impl Default for LspClientState { Self { next_request_id: default_next_request_id(), initialized: false, + shutdown_requested: false, server_capabilities: Vec::new(), open_documents: BTreeMap::new(), pending_requests: BTreeMap::new(), @@ -317,6 +320,21 @@ pub struct ClientChangeDocumentRequest { pub text: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCloseDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientShutdownRequest { + #[serde(default)] + pub state: LspClientState, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClientFeatureRequest { @@ -568,9 +586,7 @@ pub fn client_initialize(request: ClientInitializeRequest) -> Result Result Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.remove(&request.uri) else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot close a document that is not open in the LSP client.", + )); + }; + let message = json_rpc_notification( + "textDocument/didClose", + json!({ + "textDocument": { + "uri": document.uri + } + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_shutdown(request: ClientShutdownRequest) -> Result { + let mut state = request.state; + if state.shutdown_requested { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP client shutdown has already been requested.", + )); + } + let id = allocate_request(&mut state, "shutdown"); + state.shutdown_requested = true; + let message = json_rpc_message_without_params(Some(&id), "shutdown")?; + Ok(client_response(state, vec![message], Vec::new())) +} + pub fn client_feature_request( request: ClientFeatureRequest, ) -> Result { @@ -700,6 +755,7 @@ pub fn client_apply_server_message( let mut events = Vec::new(); if let Some(method) = message.get("method").and_then(Value::as_str) { + let request_id = message.get("id"); match method { "textDocument/publishDiagnostics" => { if let Some(params) = message.get("params") { @@ -725,26 +781,48 @@ pub fn client_apply_server_message( } "client/registerCapability" => { apply_dynamic_registration(&mut state, &message); - if let Some(id) = lsp_message_id(&message) { - responses.push(json_rpc_result(&id, Value::Null)?); + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); } } "client/unregisterCapability" => { apply_dynamic_unregistration(&mut state, &message); - if let Some(id) = lsp_message_id(&message) { - responses.push(json_rpc_result(&id, Value::Null)?); + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + "workspace/configuration" => { + if let Some(id) = request_id { + let item_count = message + .get("params") + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + responses.push(json_rpc_result( + id, + Value::Array(vec![Value::Null; item_count]), + )?); + } + } + "workspace/workspaceFolders" | "window/workDoneProgress/create" => { + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); } } _ => { - events.push(LspClientEvent { - kind: "notification".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - diagnostics: None, - result: message.get("params").cloned(), - error: None, - }); + if let Some(id) = request_id { + responses.push(json_rpc_error(id, -32601, "Method not found")?); + } else { + events.push(LspClientEvent { + kind: "notification".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + diagnostics: None, + result: message.get("params").cloned(), + error: None, + }); + } } } } else if let Some(id) = lsp_message_id(&message) { @@ -758,6 +836,14 @@ pub fn client_apply_server_message( responses.push(json_rpc_notification("initialized", json!({}))?); } } + if pending.as_deref() == Some("shutdown") { + state.initialized = false; + state.shutdown_requested = false; + state.server_capabilities.clear(); + state.open_documents.clear(); + state.diagnostics.clear(); + responses.push(json_rpc_message_without_params(None, "exit")?); + } let result = lsp_feature_result_for_method(pending.as_deref(), message.get("result")); events.push(LspClientEvent { kind: if message.get("error").is_some() { @@ -947,7 +1033,18 @@ fn json_rpc_notification(method: &str, params: Value) -> Result Result { +fn json_rpc_message_without_params(id: Option<&str>, method: &str) -> Result { + let mut message = json!({ + "jsonrpc": "2.0", + "method": method + }); + if let Some(id) = id { + message["id"] = Value::String(id.to_string()); + } + encode_json_rpc(message) +} + +fn json_rpc_result(id: &Value, result: Value) -> Result { encode_json_rpc(json!({ "jsonrpc": "2.0", "id": id, @@ -955,6 +1052,17 @@ fn json_rpc_result(id: &str, result: Value) -> Result { })) } +fn json_rpc_error(id: &Value, code: i64, message: &str) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + })) +} + fn encode_json_rpc(value: Value) -> Result { serde_json::to_string(&value).map_err(|error| { CoreError::new(ErrorCode::Unknown, "Could not encode LSP JSON-RPC message") @@ -1586,7 +1694,33 @@ fn parse_lsp_position_value(value: &Value) -> Option { } fn file_path_from_uri(uri: &str) -> String { - uri.strip_prefix("file://").unwrap_or(uri).to_string() + let path = uri.strip_prefix("file://").unwrap_or(uri); + let mut decoded = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8(decoded).unwrap_or_else(|_| path.to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } } fn feature_names_from_capabilities(capabilities: &Value) -> Vec { @@ -2503,6 +2637,14 @@ mod tests { assert_eq!(references.locations.len(), 2); } + #[test] + fn file_uri_paths_decode_spaces_and_utf8_characters() { + assert_eq!( + file_path_from_uri("file:///tmp/go%20project/%E4%B8%AD%E6%96%87/main.go"), + "/tmp/go project/中文/main.go" + ); + } + #[test] fn client_core_initializes_and_applies_server_capabilities() { let initialized = client_initialize(ClientInitializeRequest { @@ -2522,6 +2664,13 @@ mod tests { initialize_message["params"]["rootUri"], "file:///tmp/project" ); + let client_capabilities = &initialize_message["params"]["capabilities"]; + assert_eq!(client_capabilities["workspace"]["configuration"], true); + assert!(client_capabilities["workspace"].get("applyEdit").is_none()); + assert!(client_capabilities["textDocument"]["synchronization"] + .get("didSave") + .is_none()); + assert_eq!(client_capabilities["window"]["workDoneProgress"], true); let applied = client_apply_server_message(ClientApplyServerMessageRequest { state: initialized.state, @@ -2621,6 +2770,123 @@ mod tests { assert_eq!(request_message["params"]["position"]["character"], 12); } + #[test] + fn client_core_closes_open_documents() { + let uri = "file:///tmp/project/main.go"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "go".to_string(), + text: "package main\n".to_string(), + }) + .unwrap(); + + let closed = client_close_document(ClientCloseDocumentRequest { + state: opened.state, + uri: uri.to_string(), + }) + .unwrap(); + + assert!(!closed.state.open_documents.contains_key(uri)); + assert_eq!(closed.messages.len(), 1); + let did_close: Value = serde_json::from_str(&closed.messages[0]).unwrap(); + assert_eq!( + did_close, + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": { + "textDocument": { + "uri": uri + } + } + }) + ); + + let error = client_close_document(ClientCloseDocumentRequest { + state: closed.state, + uri: uri.to_string(), + }) + .unwrap_err(); + assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); + } + + #[test] + fn client_core_waits_for_shutdown_response_before_exiting() { + let mut state = LspClientState { + initialized: true, + ..LspClientState::default() + }; + state.server_capabilities.push("completion".to_string()); + state.open_documents.insert( + "file:///tmp/project/main.go".to_string(), + LspClientDocument { + uri: "file:///tmp/project/main.go".to_string(), + language_id: "go".to_string(), + version: 1, + text: "package main\n".to_string(), + }, + ); + + let shutting_down = client_shutdown(ClientShutdownRequest { state }).unwrap(); + assert!(shutting_down.state.shutdown_requested); + assert_eq!( + shutting_down.state.pending_requests.get("1"), + Some(&"shutdown".to_string()) + ); + let shutdown: Value = serde_json::from_str(&shutting_down.messages[0]).unwrap(); + assert_eq!( + shutdown, + json!({ + "jsonrpc": "2.0", + "id": "1", + "method": "shutdown" + }) + ); + + let exited = client_apply_server_message(ClientApplyServerMessageRequest { + state: shutting_down.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": null + }) + .to_string(), + }) + .unwrap(); + + assert!(!exited.state.initialized); + assert!(!exited.state.shutdown_requested); + assert!(exited.state.pending_requests.is_empty()); + assert!(exited.state.server_capabilities.is_empty()); + assert!(exited.state.open_documents.is_empty()); + assert_eq!(exited.messages.len(), 1); + let exit: Value = serde_json::from_str(&exited.messages[0]).unwrap(); + assert_eq!( + exit, + json!({ + "jsonrpc": "2.0", + "method": "exit" + }) + ); + assert_eq!(exited.events.len(), 1); + assert_eq!(exited.events[0].method.as_deref(), Some("shutdown")); + } + + #[test] + fn client_core_rejects_duplicate_shutdown_requests() { + let shutting_down = client_shutdown(ClientShutdownRequest { + state: LspClientState::default(), + }) + .unwrap(); + + let error = client_shutdown(ClientShutdownRequest { + state: shutting_down.state, + }) + .unwrap_err(); + assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); + } + #[test] fn frame_message_uses_lsp_content_length_bytes() { let message = @@ -3095,6 +3361,125 @@ mod tests { .server_capabilities .contains(&"formatting".to_string())); let response: Value = serde_json::from_str(®istered.messages[0]).unwrap(); - assert_eq!(response["id"], "77"); + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "id": 77, "result": null }) + ); + + let unregistered = client_apply_server_message(ClientApplyServerMessageRequest { + state: registered.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "unregister-1", + "method": "client/unregisterCapability", + "params": { + "unregisterations": [{ + "id": "formatting", + "method": "textDocument/formatting" + }] + } + }"# + .to_string(), + }) + .unwrap(); + assert!(!unregistered + .state + .server_capabilities + .contains(&"formatting".to_string())); + let response: Value = serde_json::from_str(&unregistered.messages[0]).unwrap(); + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "id": "unregister-1", "result": null }) + ); + } + + #[test] + fn client_core_answers_workspace_configuration_requests_by_item() { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: r#"{ + "jsonrpc": "2.0", + "id": "configuration-1", + "method": "workspace/configuration", + "params": { + "items": [ + { "section": "gopls" }, + { "scopeUri": "file:///tmp/project", "section": "gopls.ui" } + ] + } + }"# + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ + "jsonrpc": "2.0", + "id": "configuration-1", + "result": [null, null] + }) + ); + } + + #[test] + fn client_core_answers_workspace_folder_and_progress_requests() { + for (method, id) in [ + ("workspace/workspaceFolders", json!(42)), + ("window/workDoneProgress/create", json!("progress-1")), + ] { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": {} + }) + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ "jsonrpc": "2.0", "id": id, "result": null }) + ); + } + } + + #[test] + fn client_core_rejects_unknown_server_requests_with_method_not_found() { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: r#"{ + "jsonrpc": "2.0", + "id": 91, + "method": "experimental/notSupported", + "params": { "value": true } + }"# + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ + "jsonrpc": "2.0", + "id": 91, + "error": { + "code": -32601, + "message": "Method not found" + } + }) + ); } } diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index e1e5e30d..5539d579 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -390,6 +390,39 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspClientCloseDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP close document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_close_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientShutdown => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP shutdown request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_shutdown) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspClientRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -907,3 +940,68 @@ fn execute(request: &str) -> CoreResponse { response } } + +#[cfg(test)] +mod tests { + use super::execute_json; + use serde_json::{json, Value}; + + #[test] + fn routes_lsp_close_document_and_shutdown_commands() { + let uri = "file:///tmp/project/main.go"; + let close_response: Value = serde_json::from_str(&execute_json( + &json!({ + "id": "close-1", + "command": "lsp.clientCloseDocument", + "payload": { + "state": { + "openDocuments": { + uri: { + "uri": uri, + "languageId": "go", + "version": 1, + "text": "package main\n" + } + } + }, + "uri": uri + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(close_response["ok"], true); + assert!(close_response["data"]["state"]["openDocuments"] + .as_object() + .unwrap() + .is_empty()); + let did_close: Value = + serde_json::from_str(close_response["data"]["messages"][0].as_str().unwrap()).unwrap(); + assert_eq!(did_close["method"], "textDocument/didClose"); + + let shutdown_response: Value = serde_json::from_str(&execute_json( + &json!({ + "id": "shutdown-1", + "command": "lsp.clientShutdown", + "payload": { + "state": { + "initialized": true + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(shutdown_response["ok"], true); + assert_eq!( + shutdown_response["data"]["state"]["shutdownRequested"], + true + ); + let shutdown: Value = + serde_json::from_str(shutdown_response["data"]["messages"][0].as_str().unwrap()) + .unwrap(); + assert_eq!(shutdown["method"], "shutdown"); + assert_eq!(shutdown["id"], "1"); + assert!(shutdown.get("params").is_none()); + } +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 2888e240..80dfe933 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -10,8 +10,10 @@ verification scripts are the executable source of boundary checks. - All payloads are UTF-8 JSON when exchanged across a process or language boundary. - Workspace paths are relative to the opened workspace and use `/` separators. -- Absolute paths may appear only in platform-owned diagnostics and are never used as identifiers. -- Line numbers are one-based. Missing locations are `null`. +- Absolute paths may appear at native editor/process boundaries and as LSP + `file://` URIs, but are not persisted as cross-platform identifiers. +- Product-facing line numbers are one-based. Editor/LSP positions explicitly use + zero-based lines and UTF-16 columns. Missing locations are `null`. - Lists have deterministic ordering so contract fixtures can be compared directly. - Every asynchronous operation exposes `idle`, `loading`, `ready`, and `failed` outcomes. - Failures contain a stable `code` and user-facing `message`; platform details belong in `details`. @@ -25,6 +27,7 @@ verification scripts are the executable source of boundary checks. | Search | query matching, deterministic result ordering, symbols, and replacement preview | workspace lifecycle and optional index persistence | | Git | changes, commits, branches, diffs, history, validation, and mutation results | Git executable discovery, credentials, process environment | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | +| Language tooling | provider catalog, local fallback results, LSP state, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable discovery, stdio process transport, environment, and termination | | Java/Maven | Maven project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, and run-configuration detection | JDK/Maven discovery, JDT LS, Java/Maven child processes, sockets, JDB/LSP transport | | Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | @@ -37,6 +40,14 @@ ignore stale termination events after a restart. `stop()` is the cancellation operation and must terminate the platform process without changing feature state owned by another operation. +Language feature clients route through a provider interface rather than +depending directly on an LSP session. Process-free providers remain available +when an executable is missing. LSP-backed features are enabled only after the +server advertises them during initialize or dynamic registration. The shared +core owns JSON-RPC state and normalized results; platform adapters own stdio and +process lifecycle. Detailed invariants are documented in +[`language-tooling.md`](../../docs/architecture/language-tooling.md). + ## Error Codes Use stable categories rather than platform error strings: diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 4623f756..42258a71 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -78,8 +78,12 @@ stable error code and a user-facing message: | `lsp.clientInitialize` | Create an LSP initialize JSON-RPC request and client state | | `lsp.clientOpenDocument` | Track an open document and emit `textDocument/didOpen` | | `lsp.clientChangeDocument` | Track a full-text document change and emit `textDocument/didChange` | +| `lsp.clientCloseDocument` | Remove an open document and emit `textDocument/didClose` | +| `lsp.clientShutdown` | Record graceful shutdown and emit the `shutdown` request | | `lsp.clientRequest` | Emit a typed LSP feature request and record the pending request | | `lsp.clientApplyServerMessage` | Apply LSP server responses, diagnostics, and dynamic registrations | +| `lsp.frameMessage` | Frame one JSON-RPC payload for an LSP stdio transport | +| `lsp.parseServerMessages` | Incrementally parse framed messages from an LSP stdout byte stream | | `java.runConfigurations` | Scan Java sources for main classes and return Maven/Spring run configurations | | `java.codeVision` | Return Java declaration usage counts for editor code vision | | `java.className` | Resolve a Java source package and simple name into a runtime class name | @@ -205,7 +209,10 @@ The LSP provider catalog is returned by `lithe_core_lsp_provider_catalog_json`. Each provider descriptor may include `languageServerLaunch` with ordered `executableNames` and `arguments`; Swift adapters use this metadata when they need to start a real language server after the lightweight Rust fallback is not -enough. +enough. Built-in descriptors are merged by provider ID with the optional +`.lithe/lsp/language-providers.json` workspace document. See +[`language-tooling.md`](../../docs/architecture/language-tooling.md) for routing, +discovery, lifecycle, and compatibility rules. `lsp.client*` commands are the transport-independent LSP client core. The platform adapter owns the process/stdin/stdout transport and passes a @@ -213,13 +220,17 @@ serialized `state` object through these commands. Responses return `{ "state": object, "messages": string[], "events": [] }`; `messages` are raw JSON-RPC payloads for the adapter to frame and write to the language server. `lsp.clientInitialize` records the pending initialize request and emits -`initialize`. `lsp.clientOpenDocument` and `lsp.clientChangeDocument` maintain -document versions and emit full-text sync notifications. `lsp.clientRequest` -supports completion, hover, definition/declaration/typeDefinition, +`initialize`. `lsp.clientOpenDocument`, `lsp.clientChangeDocument`, and +`lsp.clientCloseDocument` maintain document state and emit full-text lifecycle +notifications. `lsp.clientShutdown` emits `shutdown`; applying its response +clears session state and emits `exit`. `lsp.clientRequest` supports completion, +hover, definition/declaration/typeDefinition, implementation, references, rename, formatting, code action, resolve, and execute-command methods. `lsp.clientApplyServerMessage` parses server responses, derives feature names from initialize capabilities, stores -`publishDiagnostics`, and handles dynamic register/unregister notifications. +`publishDiagnostics`, handles dynamic register/unregister notifications, and +answers the supported workspace/window requests. Unknown server requests +receive JSON-RPC `Method not found` instead of being silently ignored. Completion, hover, and navigation responses are normalized by Rust into the same completion item, hover, and location payload shapes used by the lightweight fallback commands. From 978e56153cfa149d5b71b1cf9c1904c6d2424748 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 09:07:48 +0800 Subject: [PATCH 21/38] Make LSP providers dynamically configurable --- .../Lithe/Core/Ports/LanguageTooling.swift | 31 ++++- Sources/Lithe/Core/RustCoreBridge.swift | 11 +- .../RustLanguageProviderCatalogSource.swift | 22 +++- .../LanguageToolingSessionManager.swift | 40 ++++++- .../StdioLanguageProviderRuntime.swift | 73 +++++++++++- .../Services/StdioLanguageServerSession.swift | 19 ++- .../RunConfigurationIntegrationTests.swift | 74 +++++++++++- docs/reference/language-providers.schema.json | 104 ++++++++++++++++ .../resources/lsp/language-providers.json | 49 +++++++- rust/lithe-core/src/lsp.rs | 112 ++++++++++++++++-- 10 files changed, 511 insertions(+), 24 deletions(-) create mode 100644 docs/reference/language-providers.schema.json diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index ec197dfe..47d03b41 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -59,6 +59,25 @@ enum ToolingActivationPolicy: String, Codable, Hashable, Sendable { struct LanguageServerLaunchDescriptor: Hashable, Sendable { let executableNames: [String] let arguments: [String] + let environment: [String: String] + let initializationOptions: ToolingJSONValue? + + init( + executableNames: [String], + arguments: [String] = [], + environment: [String: String] = [:], + initializationOptions: ToolingJSONValue? = nil + ) { + self.executableNames = executableNames + self.arguments = arguments + self.environment = environment + self.initializationOptions = initializationOptions + } +} + +struct LanguageServerInstallationDescriptor: Hashable, Sendable { + let homebrewFormula: String? + let officialDownloadURL: URL? } struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { @@ -73,6 +92,7 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { let languageIdentifiersByExtension: [String: String] let languageIdentifiersByFileName: [String: String] let languageServerLaunch: LanguageServerLaunchDescriptor? + let languageServerInstallation: LanguageServerInstallationDescriptor? init( id: String, @@ -85,7 +105,8 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { languageIdentifier: String? = nil, languageIdentifiersByExtension: [String: String] = [:], languageIdentifiersByFileName: [String: String] = [:], - languageServerLaunch: LanguageServerLaunchDescriptor? = nil + languageServerLaunch: LanguageServerLaunchDescriptor? = nil, + languageServerInstallation: LanguageServerInstallationDescriptor? = nil ) { self.id = id self.displayName = displayName @@ -106,6 +127,7 @@ struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { } ) self.languageServerLaunch = languageServerLaunch + self.languageServerInstallation = languageServerInstallation } func handles(fileURL: URL) -> Bool { @@ -428,7 +450,7 @@ extension DebugAdapterSession { var state: DebugAdapterState { isRunning ? .running : .idle } } -enum ToolingJSONValue: Codable, Equatable, Sendable { +enum ToolingJSONValue: Codable, Equatable, Hashable, Sendable { case string(String) case integer(Int) case number(Double) @@ -636,6 +658,11 @@ protocol LanguageProviderRuntime: AnyObject { func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? } +@MainActor +protocol LanguageProviderRuntimeFactory: AnyObject { + func makeRuntime(for descriptor: LanguageProviderDescriptor) -> (any LanguageProviderRuntime)? +} + extension LanguageProviderRuntime { var supportsLanguageServerSession: Bool { false } var supportsDebugAdapterSession: Bool { false } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 3b7ebab8..54d19564 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1059,6 +1059,7 @@ struct RustCoreBridge: Sendable { let state: ToolingJSONValue? let rootUri: String let processId: Int? + let initializationOptions: ToolingJSONValue? } private struct LspClientOpenDocumentRequest: Encodable { @@ -2097,12 +2098,20 @@ struct RustCoreBridge: Sendable { } func lspClientInitialize(rootURL: URL) -> LspClientResponsePayload? { + lspClientInitialize(rootURL: rootURL, initializationOptions: nil) + } + + func lspClientInitialize( + rootURL: URL, + initializationOptions: ToolingJSONValue? + ) -> LspClientResponsePayload? { execute( command: "lsp.clientInitialize", payload: LspClientInitializeRequest( state: nil, rootUri: rootURL.standardizedFileURL.absoluteString, - processId: Int(ProcessInfo.processInfo.processIdentifier) + processId: Int(ProcessInfo.processInfo.processIdentifier), + initializationOptions: initializationOptions ) ) } diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift index b7bee0e1..c0901bef 100644 --- a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift +++ b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift @@ -13,11 +13,27 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { private struct LanguageServerLaunchPayload: Decodable { let executableNames: [String] let arguments: [String] + let environment: [String: String] + let initializationOptions: ToolingJSONValue? func makeDescriptor() -> LanguageServerLaunchDescriptor { LanguageServerLaunchDescriptor( executableNames: executableNames, - arguments: arguments + arguments: arguments, + environment: environment, + initializationOptions: initializationOptions + ) + } + } + + private struct LanguageServerInstallationPayload: Decodable { + let homebrewFormula: String? + let officialDownloadURL: String? + + func makeDescriptor() -> LanguageServerInstallationDescriptor { + LanguageServerInstallationDescriptor( + homebrewFormula: homebrewFormula, + officialDownloadURL: officialDownloadURL.flatMap(URL.init(string:)) ) } } @@ -34,6 +50,7 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { let languageIdsByExtension: [String: String] let languageIdsByFileName: [String: String] let languageServerLaunch: LanguageServerLaunchPayload? + let languageServerInstallation: LanguageServerInstallationPayload? func makeDescriptor() -> LanguageProviderDescriptor { LanguageProviderDescriptor( @@ -47,7 +64,8 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { languageIdentifier: languageId, languageIdentifiersByExtension: languageIdsByExtension, languageIdentifiersByFileName: languageIdsByFileName, - languageServerLaunch: languageServerLaunch?.makeDescriptor() + languageServerLaunch: languageServerLaunch?.makeDescriptor(), + languageServerInstallation: languageServerInstallation?.makeDescriptor() ) } } diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 7b2b4a87..78b25dfa 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -35,6 +35,7 @@ final class LanguageToolingSessionManager: ObservableObject { private var catalog: LanguageProviderCatalog private var runtimesByID: [String: any LanguageProviderRuntime] + private let runtimeFactory: (any LanguageProviderRuntimeFactory)? private var languageServers: [String: any LanguageServerSession] = [:] private var languageServerRoots: [String: URL] = [:] private var languageFeatureProviders: [any LanguageFeatureProvider] @@ -46,10 +47,12 @@ final class LanguageToolingSessionManager: ObservableObject { init( catalog: LanguageProviderCatalog = .standard, runtimes: [any LanguageProviderRuntime] = [], + runtimeFactory: (any LanguageProviderRuntimeFactory)? = nil, core: RustCoreBridge = RustCoreBridge(), languageFeatureProviders: [any LanguageFeatureProvider] = [] ) { self.catalog = catalog + self.runtimeFactory = runtimeFactory self.languageFeatureProviders = languageFeatureProviders + [ BuiltinLanguageFeatureProvider(core: core) ] @@ -64,12 +67,26 @@ final class LanguageToolingSessionManager: ObservableObject { var activeDebugAdapterIDs: Set { Set(debugAdapters.keys) } func updateCatalog(_ catalog: LanguageProviderCatalog) { + let previousDescriptors = Dictionary( + uniqueKeysWithValues: self.catalog.descriptors.map { ($0.id, $0) } + ) + let updatedDescriptors = Dictionary( + uniqueKeysWithValues: catalog.descriptors.map { ($0.id, $0) } + ) + let changedProviderIDs = Set(previousDescriptors.keys) + .union(updatedDescriptors.keys) + .filter { previousDescriptors[$0] != updatedDescriptors[$0] } + self.catalog = catalog let validProviderIDs = Set(catalog.descriptors.map(\.id)) languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } diagnostics = diagnostics.filter { catalog.provider(for: $0.key) != nil } - for providerID in Array(languageServers.keys) where !validProviderIDs.contains(providerID) { + for providerID in changedProviderIDs { stopLanguageServer(providerID: providerID) + stopDebugAdapter(providerID: providerID) + if runtimeFactory != nil { + runtimesByID[providerID] = nil + } } } @@ -84,7 +101,7 @@ final class LanguageToolingSessionManager: ObservableObject { func supportsGenericDebugging(for fileURL: URL) -> Bool { guard let descriptor = catalog.provider(for: fileURL), descriptor.capabilities.contains(.debugAdapter) else { return false } - return runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true + return runtime(for: descriptor)?.supportsDebugAdapterSession == true } func features(for fileURL: URL) -> LanguageServerFeatureSet { @@ -122,7 +139,7 @@ final class LanguageToolingSessionManager: ObservableObject { throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) } guard descriptor.capabilities.contains(.languageServer) else { return } - guard let runtime = runtimesByID[descriptor.id], + guard let runtime = runtime(for: descriptor), runtime.supportsLanguageServerSession else { return } @@ -430,7 +447,7 @@ final class LanguageToolingSessionManager: ObservableObject { debugAdapters[descriptor.id] = nil debugAdapterRoots[descriptor.id] = nil } - guard let runtime = runtimesByID[descriptor.id] else { + guard let runtime = runtime(for: descriptor) else { throw LanguageToolingSessionError.providerNotInstalled(descriptor.displayName) } guard let session = runtime.makeDebugAdapterSession(rootURL: normalizedRoot) else { @@ -451,6 +468,21 @@ final class LanguageToolingSessionManager: ObservableObject { return session } + private func runtime( + for descriptor: LanguageProviderDescriptor + ) -> (any LanguageProviderRuntime)? { + if let existing = runtimesByID[descriptor.id], + existing.descriptor == descriptor { + return existing + } + guard let runtimeFactory, + let runtime = runtimeFactory.makeRuntime(for: descriptor) else { + return runtimesByID[descriptor.id] + } + runtimesByID[descriptor.id] = runtime + return runtime + } + func stopDebugAdapter(providerID: String) { debugAdapters.removeValue(forKey: providerID)?.stop() debugAdapterRoots[providerID] = nil diff --git a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift index c5bdbbc9..a3798ff6 100644 --- a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift +++ b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift @@ -7,6 +7,7 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { private let processFactory: () -> any RawProcessSession private let languageServerLaunch: LanguageServerLaunchDescriptor? private let languageServerCore: any LspClientCore + private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? private let debugLaunch: StdioDebugAdapterLaunch? private let debugSessionFactory: (() -> (any DebugAdapterSession)?)? @@ -30,6 +31,7 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { processFactory: @escaping () -> any RawProcessSession, languageServerLaunch: LanguageServerLaunchDescriptor? = nil, languageServerCore: any LspClientCore = RustCoreBridge(), + languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, debugLaunch: StdioDebugAdapterLaunch? = nil, debugSessionFactory: (() -> (any DebugAdapterSession)?)? = nil ) { @@ -38,19 +40,25 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { self.processFactory = processFactory self.languageServerLaunch = languageServerLaunch self.languageServerCore = languageServerCore + self.languageServerExecutableResolver = languageServerExecutableResolver self.debugLaunch = debugLaunch self.debugSessionFactory = debugSessionFactory } func makeLanguageServerSession() -> (any LanguageServerSession)? { guard let languageServerLaunch else { return nil } - guard let executableURL = languageServerLaunch.executableNames.lazy.compactMap({ - self.runtimeService.executableOnPath($0) - }).first else { return nil } + let configuredExecutable = languageServerExecutableResolver?(descriptor) + guard let executableURL = configuredExecutable + ?? languageServerLaunch.executableNames.lazy.compactMap({ + self.runtimeService.executableOnPath($0) + }).first else { return nil } + var environment = runtimeService.processEnvironment() + environment.merge(languageServerLaunch.environment) { _, configured in configured } return StdioLanguageServerSession( executableURL: executableURL, arguments: languageServerLaunch.arguments, - environment: runtimeService.processEnvironment(), + environment: environment, + initializationOptions: languageServerLaunch.initializationOptions, process: processFactory(), core: languageServerCore ) @@ -81,6 +89,7 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { packs: [LanguagePack], runtimeService: ProjectRuntimeService, processFactory: @escaping () -> any RawProcessSession, + languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] ) -> [any LanguageProviderRuntime] { packs.compactMap { pack in @@ -94,6 +103,7 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { runtimeService: runtimeService, processFactory: processFactory, languageServerLaunch: pack.descriptor.languageServerLaunch, + languageServerExecutableResolver: languageServerExecutableResolver, debugLaunch: pack.debugAdapterLaunch, debugSessionFactory: debugSessionFactories[pack.descriptor.id] ) @@ -104,13 +114,68 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime { catalog: LanguageProviderCatalog, runtimeService: ProjectRuntimeService, processFactory: @escaping () -> any RawProcessSession, + languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] ) -> [any LanguageProviderRuntime] { standard( packs: LanguagePackRegistry.standard(catalog: catalog).packs, runtimeService: runtimeService, processFactory: processFactory, + languageServerExecutableResolver: languageServerExecutableResolver, debugSessionFactories: debugSessionFactories ) } } + +@MainActor +final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { + private let runtimeService: ProjectRuntimeService + private let processFactory: () -> any RawProcessSession + private let languageServerCore: any LspClientCore + private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? + private let debugLaunches: [String: StdioDebugAdapterLaunch] + private let debugSessionFactories: [String: () -> (any DebugAdapterSession)?] + + init( + runtimeService: ProjectRuntimeService, + processFactory: @escaping () -> any RawProcessSession, + languageServerCore: any LspClientCore = RustCoreBridge(), + languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + debugLaunches: [String: StdioDebugAdapterLaunch] = [:], + debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] + ) { + self.runtimeService = runtimeService + self.processFactory = processFactory + self.languageServerCore = languageServerCore + self.languageServerExecutableResolver = languageServerExecutableResolver + self.debugLaunches = debugLaunches + self.debugSessionFactories = debugSessionFactories + } + + func makeRuntime( + for descriptor: LanguageProviderDescriptor + ) -> (any LanguageProviderRuntime)? { + let languageServerLaunch = descriptor.capabilities.contains(.languageServer) + ? descriptor.languageServerLaunch + : nil + let debugLaunch = descriptor.capabilities.contains(.debugAdapter) + ? debugLaunches[descriptor.id] + : nil + let debugSessionFactory = descriptor.capabilities.contains(.debugAdapter) + ? debugSessionFactories[descriptor.id] + : nil + guard languageServerLaunch != nil || debugLaunch != nil || debugSessionFactory != nil else { + return nil + } + return StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + processFactory: processFactory, + languageServerLaunch: languageServerLaunch, + languageServerCore: languageServerCore, + languageServerExecutableResolver: languageServerExecutableResolver, + debugLaunch: debugLaunch, + debugSessionFactory: debugSessionFactory + ) + } +} diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index cd902743..54523487 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -2,6 +2,10 @@ import Foundation protocol LspClientCore: Sendable { func lspClientInitialize(rootURL: URL) -> RustCoreBridge.LspClientResponsePayload? + func lspClientInitialize( + rootURL: URL, + initializationOptions: ToolingJSONValue? + ) -> RustCoreBridge.LspClientResponsePayload? func lspClientOpenDocument( state: ToolingJSONValue, fileURL: URL, @@ -46,6 +50,13 @@ protocol LspClientCore: Sendable { extension RustCoreBridge: LspClientCore {} extension LspClientCore { + func lspClientInitialize( + rootURL: URL, + initializationOptions _: ToolingJSONValue? + ) -> RustCoreBridge.LspClientResponsePayload? { + lspClientInitialize(rootURL: rootURL) + } + func lspClientCloseDocument( state _: ToolingJSONValue, fileURL _: URL @@ -61,6 +72,7 @@ final class StdioLanguageServerSession: LanguageServerSession { private let executableURL: URL private let arguments: [String] private let environment: [String: String] + private let initializationOptions: ToolingJSONValue? private let process: any RawProcessSession private let core: any LspClientCore private var state: ToolingJSONValue? @@ -80,12 +92,14 @@ final class StdioLanguageServerSession: LanguageServerSession { executableURL: URL, arguments: [String], environment: [String: String], + initializationOptions: ToolingJSONValue? = nil, process: any RawProcessSession, core: any LspClientCore = RustCoreBridge() ) { self.executableURL = executableURL self.arguments = arguments self.environment = environment + self.initializationOptions = initializationOptions self.process = process self.core = core process.onOutput = { [weak self] data in @@ -107,7 +121,10 @@ final class StdioLanguageServerSession: LanguageServerSession { environment: environment, keepsStandardInputOpen: true )) - guard let response = core.lspClientInitialize(rootURL: rootURL) else { return } + guard let response = core.lspClientInitialize( + rootURL: rootURL, + initializationOptions: initializationOptions + ) else { return } apply(response) } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index ada76ddf..525cf48a 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -152,6 +152,32 @@ struct RunConfigurationIntegrationTests { #expect(registry.testProviders.provider(id: "ruby")?.descriptor.id == "ruby") } + @Test + func projectCatalogCanCreateRuntimeForANewProviderDynamically() { + let factory = TestLanguageProviderRuntimeFactory() + let manager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: []), + runtimeFactory: factory + ) + let descriptor = LanguageProviderDescriptor( + id: "roc", + displayName: "Roc", + fileExtensions: ["roc"], + capabilities: [.languageServer, .debugAdapter], + activationPolicy: .onDemand, + languageIdentifier: "roc", + languageServerLaunch: LanguageServerLaunchDescriptor( + executableNames: ["roc_language_server"], + arguments: [] + ) + ) + + manager.updateCatalog(LanguageProviderCatalog(descriptors: [descriptor])) + + #expect(manager.supportsGenericDebugging(for: URL(fileURLWithPath: "/tmp/main.roc"))) + #expect(factory.createdDescriptors == [descriptor]) + } + @Test func genericDebugCapabilityReflectsTheRuntimeFactoryWithoutStartingIt() throws { let catalog = LanguageProviderCatalog.standard @@ -1028,7 +1054,9 @@ struct RunConfigurationIntegrationTests { languageIdentifier: "swift", languageServerLaunch: LanguageServerLaunchDescriptor( executableNames: ["sourcekit-lsp"], - arguments: [] + arguments: [], + environment: ["SOURCEKIT_TOOLCHAIN": "custom"], + initializationOptions: .object(["indexing": .bool(true)]) ) ) let runtimeService = ProjectRuntimeService( @@ -1036,6 +1064,7 @@ struct RunConfigurationIntegrationTests { store: RunTestKeyValueStore() ) let process = RecordingRawProcessSession() + let initializationRecorder = TestLspInitializationRecorder() let root = URL(fileURLWithPath: "/tmp/swift-project", isDirectory: true) let source = root.appendingPathComponent("App.swift") let runtime = StdioLanguageProviderRuntime( @@ -1043,7 +1072,10 @@ struct RunConfigurationIntegrationTests { runtimeService: runtimeService, processFactory: { process }, languageServerLaunch: descriptor.languageServerLaunch, - languageServerCore: TestLspClientCore(diagnosticURL: source) + languageServerCore: TestLspClientCore( + diagnosticURL: source, + initializationRecorder: initializationRecorder + ) ) let manager = LanguageToolingSessionManager( catalog: LanguageProviderCatalog(descriptors: [descriptor]), @@ -1058,6 +1090,8 @@ struct RunConfigurationIntegrationTests { let startRequest = try #require(process.requests.first) #expect(startRequest.executablePath == "/usr/bin/sourcekit-lsp") #expect(startRequest.arguments.isEmpty) + #expect(startRequest.environment?["SOURCEKIT_TOOLCHAIN"] == "custom") + #expect(initializationRecorder.options == .object(["indexing": .bool(true)])) #expect(manager.activeLanguageServerIDs == ["swift"]) let firstFrameData = try #require(process.sentData.first) let firstFrame = try #require(String(data: firstFrameData, encoding: .utf8)) @@ -2973,6 +3007,15 @@ private final class RecordingRunExecutableResolver: RunExecutableResolving { private struct TestLspClientCore: LspClientCore { let diagnosticURL: URL + var initializationRecorder: TestLspInitializationRecorder? + + init( + diagnosticURL: URL, + initializationRecorder: TestLspInitializationRecorder? = nil + ) { + self.diagnosticURL = diagnosticURL + self.initializationRecorder = initializationRecorder + } func lspClientInitialize(rootURL _: URL) -> RustCoreBridge.LspClientResponsePayload? { response( @@ -2980,6 +3023,14 @@ private struct TestLspClientCore: LspClientCore { ) } + func lspClientInitialize( + rootURL: URL, + initializationOptions: ToolingJSONValue? + ) -> RustCoreBridge.LspClientResponsePayload? { + initializationRecorder?.options = initializationOptions + return lspClientInitialize(rootURL: rootURL) + } + func lspClientOpenDocument( state _: ToolingJSONValue, fileURL: URL, @@ -3345,6 +3396,10 @@ private struct TestLspClientCore: LspClientCore { } } +private final class TestLspInitializationRecorder: @unchecked Sendable { + var options: ToolingJSONValue? +} + private final class RecordingRawProcessSession: RawProcessSession, @unchecked Sendable { var isRunning = false var onOutput: (@Sendable (Data) -> Void)? @@ -3556,6 +3611,21 @@ private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime { } } +@MainActor +private final class TestLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { + private(set) var createdDescriptors: [LanguageProviderDescriptor] = [] + + func makeRuntime( + for descriptor: LanguageProviderDescriptor + ) -> (any LanguageProviderRuntime)? { + createdDescriptors.append(descriptor) + return TestDebugLanguageProviderRuntime( + descriptor: descriptor, + supportsDebugAdapter: true + ) + } +} + @MainActor private final class TestDlvSocketConnection: DlvSocketConnection { var onReady: (() -> Void)? diff --git a/docs/reference/language-providers.schema.json b/docs/reference/language-providers.schema.json new file mode 100644 index 00000000..16704f46 --- /dev/null +++ b/docs/reference/language-providers.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lithe.dev/schemas/language-providers.schema.json", + "title": "Lithe language provider catalog", + "type": "object", + "additionalProperties": false, + "required": ["version", "providers"], + "properties": { + "$schema": { "type": "string" }, + "version": { "type": "integer", "minimum": 1 }, + "providers": { + "type": "array", + "items": { "$ref": "#/$defs/provider" } + } + }, + "$defs": { + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "displayName": { "type": "string", "minLength": 1 }, + "fileExtensions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "fileNames": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "fileNamePrefixes": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "capabilities": { + "type": "array", + "items": { + "enum": ["run", "languageServer", "debugAdapter", "formatting", "testing"] + }, + "uniqueItems": true + }, + "activationPolicy": { "enum": ["onDemand", "always"] }, + "languageId": { "type": "string", "minLength": 1 }, + "languageIdsByExtension": { "$ref": "#/$defs/stringMap" }, + "languageIdsByFileName": { "$ref": "#/$defs/stringMap" }, + "languageServerLaunch": { "$ref": "#/$defs/languageServerLaunch" }, + "languageServerInstallation": { "$ref": "#/$defs/languageServerInstallation" }, + "disabled": { "type": "boolean" } + } + }, + "stringMap": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "languageServerLaunch": { + "type": "object", + "additionalProperties": false, + "required": ["executableNames"], + "properties": { + "executableNames": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]*$" + }, + "uniqueItems": true + }, + "arguments": { + "type": "array", + "items": { "type": "string" } + }, + "environment": { "$ref": "#/$defs/stringMap" }, + "initializationOptions": {} + } + }, + "languageServerInstallation": { + "type": "object", + "additionalProperties": false, + "properties": { + "homebrewFormula": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9@+._/-]*$" + }, + "officialDownloadURL": { + "type": "string", + "format": "uri", + "pattern": "^https://" + } + }, + "anyOf": [ + { "required": ["homebrewFormula"] }, + { "required": ["officialDownloadURL"] } + ] + } + } +} diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json index d1cf8036..358a37ab 100644 --- a/rust/lithe-core/resources/lsp/language-providers.json +++ b/rust/lithe-core/resources/lsp/language-providers.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "providers": [ { "id": "java", @@ -11,6 +11,10 @@ "languageServerLaunch": { "executableNames": ["jdtls"], "arguments": [] + }, + "languageServerInstallation": { + "homebrewFormula": "jdtls", + "officialDownloadURL": "https://download.eclipse.org/jdtls/milestones/" } }, { @@ -23,6 +27,10 @@ "languageServerLaunch": { "executableNames": ["gopls"], "arguments": [] + }, + "languageServerInstallation": { + "homebrewFormula": "gopls", + "officialDownloadURL": "https://go.dev/gopls/" } }, { @@ -35,6 +43,10 @@ "languageServerLaunch": { "executableNames": ["basedpyright-langserver", "pyright-langserver"], "arguments": ["--stdio"] + }, + "languageServerInstallation": { + "homebrewFormula": "pyright", + "officialDownloadURL": "https://github.com/microsoft/pyright" } }, { @@ -48,6 +60,10 @@ "executableNames": ["typescript-language-server"], "arguments": ["--stdio"] }, + "languageServerInstallation": { + "homebrewFormula": "typescript-language-server", + "officialDownloadURL": "https://github.com/typescript-language-server/typescript-language-server" + }, "languageIdsByExtension": { "jsx": "javascriptreact", "ts": "typescript", @@ -64,6 +80,10 @@ "languageServerLaunch": { "executableNames": ["rust-analyzer"], "arguments": [] + }, + "languageServerInstallation": { + "homebrewFormula": "rust-analyzer", + "officialDownloadURL": "https://rust-analyzer.github.io/manual.html#installation" } }, { @@ -77,6 +97,10 @@ "executableNames": ["clangd"], "arguments": [] }, + "languageServerInstallation": { + "homebrewFormula": "llvm", + "officialDownloadURL": "https://clangd.llvm.org/installation" + }, "languageIdsByExtension": { "c": "c", "h": "c", @@ -110,6 +134,9 @@ "languageServerLaunch": { "executableNames": ["sourcekit-lsp"], "arguments": [] + }, + "languageServerInstallation": { + "officialDownloadURL": "https://github.com/swiftlang/sourcekit-lsp" } }, { @@ -122,6 +149,9 @@ "languageServerLaunch": { "executableNames": ["kotlin-language-server"], "arguments": [] + }, + "languageServerInstallation": { + "officialDownloadURL": "https://github.com/Kotlin/kotlin-lsp" } }, { @@ -134,6 +164,10 @@ "languageServerLaunch": { "executableNames": ["metals"], "arguments": [] + }, + "languageServerInstallation": { + "homebrewFormula": "metals", + "officialDownloadURL": "https://scalameta.org/metals/docs/editors/overview.html" } }, { @@ -155,6 +189,9 @@ "languageServerLaunch": { "executableNames": ["ruby-lsp"], "arguments": [] + }, + "languageServerInstallation": { + "officialDownloadURL": "https://shopify.github.io/ruby-lsp/" } }, { @@ -167,6 +204,9 @@ "languageServerLaunch": { "executableNames": ["intelephense", "phpactor"], "arguments": [] + }, + "languageServerInstallation": { + "officialDownloadURL": "https://intelephense.com/" } }, { @@ -195,6 +235,9 @@ "languageServerLaunch": { "executableNames": ["bash-language-server"], "arguments": ["start"] + }, + "languageServerInstallation": { + "officialDownloadURL": "https://github.com/bash-lsp/bash-language-server" } }, { @@ -271,6 +314,10 @@ "languageServerLaunch": { "executableNames": ["yaml-language-server"], "arguments": ["--stdio"] + }, + "languageServerInstallation": { + "homebrewFormula": "yaml-language-server", + "officialDownloadURL": "https://github.com/redhat-developer/yaml-language-server" } }, { diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs index 43747f6b..31184324 100644 --- a/rust/lithe-core/src/lsp.rs +++ b/rust/lithe-core/src/lsp.rs @@ -37,14 +37,28 @@ pub struct LspProviderDescriptor { pub language_ids_by_extension: BTreeMap, pub language_ids_by_file_name: BTreeMap, pub language_server_launch: Option, + pub language_server_installation: Option, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct LspServerLaunchDescriptor { pub executable_names: Vec, #[serde(default)] pub arguments: Vec, + #[serde(default)] + pub environment: BTreeMap, + #[serde(default)] + pub initialization_options: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspServerInstallationDescriptor { + #[serde(default)] + pub homebrew_formula: Option, + #[serde(default, rename = "officialDownloadURL")] + pub official_download_url: Option, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] @@ -71,8 +85,10 @@ impl Default for LspActivationPolicy { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct LspProviderConfigDocument { + #[serde(default, rename = "$schema")] + _schema: Option, #[serde(default = "default_config_version")] version: u32, #[serde(default)] @@ -80,7 +96,7 @@ struct LspProviderConfigDocument { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct LspProviderPatch { id: String, #[serde(default)] @@ -104,6 +120,8 @@ struct LspProviderPatch { #[serde(default)] language_server_launch: Option, #[serde(default)] + language_server_installation: Option, + #[serde(default)] disabled: bool, } @@ -299,6 +317,8 @@ pub struct ClientInitializeRequest { pub root_uri: String, #[serde(default)] pub process_id: Option, + #[serde(default)] + pub initialization_options: Option, } #[derive(Debug, Clone, Deserialize)] @@ -590,7 +610,7 @@ pub fn client_initialize(request: ClientInitializeRequest) -> Result Result) -> LspProviderCatalog { message, }); LspProviderConfigDocument { + _schema: None, version: 1, providers: Vec::new(), } @@ -1922,6 +1944,9 @@ impl LspProviderPatch { if patch.language_server_launch.is_some() { self.language_server_launch = patch.language_server_launch; } + if patch.language_server_installation.is_some() { + self.language_server_installation = patch.language_server_installation; + } self.disabled = patch.disabled; } } @@ -1960,6 +1985,7 @@ impl LspProviderDescriptor { false, ), language_server_launch: patch.language_server_launch, + language_server_installation: patch.language_server_installation, } } } @@ -2391,6 +2417,20 @@ mod tests { swift_launch.executable_names, vec!["sourcekit-lsp".to_string()] ); + let go = catalog + .providers + .iter() + .find(|provider| provider.id == "go") + .expect("go provider should exist"); + let go_installation = go + .language_server_installation + .as_ref() + .expect("go installation descriptor should exist"); + assert_eq!(go_installation.homebrew_formula.as_deref(), Some("gopls")); + assert_eq!( + go_installation.official_download_url.as_deref(), + Some("https://go.dev/gopls/") + ); } #[test] @@ -2415,7 +2455,17 @@ mod tests { "fileExtensions": ["swift", "swiftinterface"], "languageServerLaunch": { "executableNames": ["custom-sourcekit-lsp"], - "arguments": ["--stdio"] + "arguments": ["--stdio"], + "environment": { + "SOURCEKIT_TOOLCHAIN": "custom" + }, + "initializationOptions": { + "indexing": true + } + }, + "languageServerInstallation": { + "homebrewFormula": "custom-sourcekit-lsp", + "officialDownloadURL": "https://example.com/sourcekit-lsp" } }, { @@ -2449,6 +2499,22 @@ mod tests { vec!["custom-sourcekit-lsp".to_string()] ); assert_eq!(swift_launch.arguments, vec!["--stdio".to_string()]); + assert_eq!( + swift_launch.environment.get("SOURCEKIT_TOOLCHAIN"), + Some(&"custom".to_string()) + ); + assert_eq!( + swift_launch.initialization_options, + Some(json!({ "indexing": true })) + ); + let swift_installation = swift + .language_server_installation + .as_ref() + .expect("swift installation descriptor should be overridden"); + assert_eq!( + swift_installation.homebrew_formula.as_deref(), + Some("custom-sourcekit-lsp") + ); assert!(!catalog .providers .iter() @@ -2461,12 +2527,33 @@ mod tests { fn ffi_json_is_a_standalone_catalog_document() { let raw = provider_catalog_json(None); let value: Value = serde_json::from_str(&raw).expect("catalog should be JSON"); - assert_eq!(value["version"], 1); + assert_eq!(value["version"], 2); assert!(value["providers"].as_array().unwrap().len() > 10); assert!(value.get("ok").is_none()); assert!(value.get("command").is_none()); } + #[test] + fn project_catalog_reports_unknown_configuration_fields() { + let root = temporary_root("project-config-unknown-field"); + fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); + fs::write( + root.join(".lithe/lsp/language-providers.json"), + r#"{ + "version": 2, + "providers": [{ "id": "go", "languageServerLanch": {} }] + }"#, + ) + .unwrap(); + + let catalog = provider_catalog(Some(&root)); + assert_eq!(catalog.diagnostics.len(), 1); + assert!(catalog.diagnostics[0].message.contains("unknown field")); + assert!(catalog.providers.iter().any(|provider| provider.id == "go")); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn text_edits_use_lsp_utf16_positions() { let response = apply_text_edits(ApplyTextEditsRequest { @@ -2651,6 +2738,9 @@ mod tests { state: LspClientState::default(), root_uri: "file:///tmp/project".to_string(), process_id: Some(42), + initialization_options: Some(json!({ + "ui.semanticTokens": true + })), }) .unwrap(); assert_eq!( @@ -2666,6 +2756,14 @@ mod tests { ); let client_capabilities = &initialize_message["params"]["capabilities"]; assert_eq!(client_capabilities["workspace"]["configuration"], true); + assert_eq!( + client_capabilities["textDocument"]["completion"]["completionItem"]["snippetSupport"], + false + ); + assert_eq!( + initialize_message["params"]["initializationOptions"]["ui.semanticTokens"], + true + ); assert!(client_capabilities["workspace"].get("applyEdit").is_none()); assert!(client_capabilities["textDocument"]["synchronization"] .get("didSave") From 4cdd480860d57c55bc02048d8185b0626752660d Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 09:08:23 +0800 Subject: [PATCH 22/38] Add configurable LSP tool installation --- Sources/Lithe/Application/AppServices.swift | 3 + Sources/Lithe/Core/Ports/PlatformUI.swift | 1 + Sources/Lithe/Models/AppModel.swift | 18 ++ .../Platform/MacOS/MacServiceContainer.swift | 85 ++++--- .../Runtime/MacRuntimeToolDiscovery.swift | 1 + .../Platform/MacOS/UI/MacPlatformUI.swift | 11 + .../Services/LanguageServerToolService.swift | 205 ++++++++++++++++ .../Services/ProjectRuntimeService.swift | 9 + .../LanguageServerToolServiceTests.swift | 228 ++++++++++++++++++ 9 files changed, 529 insertions(+), 32 deletions(-) create mode 100644 Sources/Lithe/Services/LanguageServerToolService.swift create mode 100644 Tests/LitheTests/LanguageServerToolServiceTests.swift diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/AppServices.swift index b56cfa40..8ba4e382 100644 --- a/Sources/Lithe/Application/AppServices.swift +++ b/Sources/Lithe/Application/AppServices.swift @@ -21,6 +21,7 @@ final class AppServices { let languageProviderCatalog: LanguageProviderCatalog let runToolchainRegistry: RunToolchainRegistry let languageToolingSessions: LanguageToolingSessionManager + let languageServerTools: LanguageServerToolService let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver let languageTestService: LanguageTestService let workspaceOperations: any WorkspaceOperations @@ -56,6 +57,7 @@ final class AppServices { languagePacks: LanguagePackRegistry? = nil, runToolchainRegistry: RunToolchainRegistry? = nil, languageToolingSessions: LanguageToolingSessionManager? = nil, + languageServerTools: LanguageServerToolService, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver? = nil, languageTestService: LanguageTestService, workspaceOperations: any WorkspaceOperations, @@ -96,6 +98,7 @@ final class AppServices { self.languageToolingSessions = languageToolingSessions ?? LanguageToolingSessionManager( registry: resolvedLanguagePacks ) + self.languageServerTools = languageServerTools self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver ?? DebugLaunchConfigurationResolver(fileStorage: fileStorage) self.languageTestService = languageTestService diff --git a/Sources/Lithe/Core/Ports/PlatformUI.swift b/Sources/Lithe/Core/Ports/PlatformUI.swift index a7ae6088..ef40d727 100644 --- a/Sources/Lithe/Core/Ports/PlatformUI.swift +++ b/Sources/Lithe/Core/Ports/PlatformUI.swift @@ -5,6 +5,7 @@ import Foundation @MainActor protocol PlatformUI: AnyObject { func chooseDirectory(title: String, prompt: String) -> URL? + func chooseFile(title: String, prompt: String) -> URL? func revealInFileBrowser(_ url: URL) func open(_ url: URL) func copyToClipboard(_ value: String) diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 5791b411..32a28cf5 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -115,6 +115,7 @@ final class AppModel: ObservableObject, Identifiable { let javaFeature: JavaFeatureModel var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations } var languageToolingSessions: LanguageToolingSessionManager { services.languageToolingSessions } + var languageServerTools: LanguageServerToolService { services.languageServerTools } var languageTestService: LanguageTestService { services.languageTestService } var languageDiagnostics: [URL: [LanguageServerDiagnostic]] { languageToolingSessions.diagnostics @@ -143,6 +144,23 @@ final class AppModel: ObservableObject, Identifiable { requestedSettingsCategory = category isSettingsPresented = true } + + func chooseLanguageServerExecutable(providerName: String) -> URL? { + platformUI.chooseFile( + title: settings.language == .simplifiedChinese + ? "选择 \(providerName) 语言服务器" + : "Choose \(providerName) language server", + prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" + ) + } + + func openLanguageServerDownload(_ url: URL) { + platformUI.open(url) + } + + func languageServerToolConfigurationDidChange(providerID: String) { + languageToolingSessions.stopLanguageServer(providerID: providerID) + } private var gitFeatureObservation: AnyCancellable? private var documentFeatureObservation: AnyCancellable? private var javaFeatureObservation: AnyCancellable? diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 818d5f52..f2f971c6 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -43,52 +43,72 @@ final class MacServiceContainer { ) let languageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) let languageProviderCatalog = languageProviderCatalogSource.catalog() + let languageServerTools = LanguageServerToolService( + runtimeService: runtimeService, + processRunner: processRunner, + store: store + ) // Build the catalog once so every standard runtime consumes the // language-pack launch metadata instead of maintaining a second map. let languagePackDefinitions = LanguagePackRegistry.standard( catalog: languageProviderCatalog ) - let languageToolingRuntimes: [any LanguageProviderRuntime] = StdioLanguageProviderRuntime.standard( - packs: languagePackDefinitions.packs, - runtimeService: runtimeService, - processFactory: { MacRawProcessSession() }, - debugSessionFactories: [ - "go": { - guard let dlv = runtimeService.executableOnPath("dlv") else { return nil } - return DebugAdapterProtocolSession( - adapterID: "go", - transport: MacDlvDebugAdapterTransport( - executableURL: dlv, - environment: runtimeService.processEnvironment(), - process: MacRawProcessSession() - ) - ) - }, - "node": { - guard let node = runtimeService.executableOnPath("node") else { return nil } - let environment = runtimeService.processEnvironment() - let locator = MacJavaScriptDebugAdapterLocator( - environment: environment, - executableOnPath: { runtimeService.executableOnPath($0) } + let debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [ + "go": { + guard let dlv = runtimeService.executableOnPath("dlv") else { return nil } + return DebugAdapterProtocolSession( + adapterID: "go", + transport: MacDlvDebugAdapterTransport( + executableURL: dlv, + environment: runtimeService.processEnvironment(), + process: MacRawProcessSession() ) - return DebugAdapterProtocolSession( - adapterID: "pwa-node", - transport: MacNodeDebugAdapterTransport( - nodeExecutableURL: node, - locator: locator, - process: MacRawProcessSession() - ) + ) + }, + "node": { + guard let node = runtimeService.executableOnPath("node") else { return nil } + let environment = runtimeService.processEnvironment() + let locator = MacJavaScriptDebugAdapterLocator( + environment: environment, + executableOnPath: { runtimeService.executableOnPath($0) } + ) + return DebugAdapterProtocolSession( + adapterID: "pwa-node", + transport: MacNodeDebugAdapterTransport( + nodeExecutableURL: node, + locator: locator, + process: MacRawProcessSession() ) - } - ] + ) + } + ] + let debugLaunches = Dictionary( + uniqueKeysWithValues: languagePackDefinitions.packs.compactMap { pack in + pack.debugAdapterLaunch.map { (pack.descriptor.id, $0) } + } + ) + let languageToolingRuntimeFactory = StdioLanguageProviderRuntimeFactory( + runtimeService: runtimeService, + processFactory: { MacRawProcessSession() }, + languageServerCore: rustCore, + languageServerExecutableResolver: { descriptor in + languageServerTools.executableURL(for: descriptor) + }, + debugLaunches: debugLaunches, + debugSessionFactories: debugSessionFactories ) + let languageToolingRuntimes: [any LanguageProviderRuntime] = languagePackDefinitions.packs + .compactMap { languageToolingRuntimeFactory.makeRuntime(for: $0.descriptor) } let languagePackRegistry = LanguagePackRegistry.standard( catalog: languageProviderCatalog, runtimes: languageToolingRuntimes ) let runToolchainRegistry = languagePackRegistry.toolchainRegistry let languageToolingSessions = LanguageToolingSessionManager( - registry: languagePackRegistry + catalog: languagePackRegistry.catalog, + runtimes: languagePackRegistry.toolingRuntimes, + runtimeFactory: languageToolingRuntimeFactory, + core: rustCore ) let testExecutableResolver = RunExecutableResolver( runtimeService: runtimeService, @@ -157,6 +177,7 @@ final class MacServiceContainer { languagePacks: languagePackRegistry, runToolchainRegistry: runToolchainRegistry, languageToolingSessions: languageToolingSessions, + languageServerTools: languageServerTools, languageTestService: languageTestService, workspaceOperations: workspaceOperations, localHistoryOperations: localHistoryOperations, diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index 51e7df07..19598ab0 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -194,6 +194,7 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { case "tsx": "tsx" case "ts-node": "ts-node" case "cargo", "rustc": "rust" + case "clangd": "llvm" case "lldb-dap": "llvm" default: command } diff --git a/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift index ea86488b..662b9b30 100644 --- a/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift +++ b/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift @@ -14,6 +14,17 @@ final class MacPlatformUI: PlatformUI { return panel.runModal() == .OK ? panel.url : nil } + func chooseFile(title: String, prompt: String) -> URL? { + let panel = NSOpenPanel() + panel.title = title + panel.prompt = prompt + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.resolvesAliases = true + return panel.runModal() == .OK ? panel.url : nil + } + func revealInFileBrowser(_ url: URL) { NSWorkspace.shared.activateFileViewerSelecting([url]) } diff --git a/Sources/Lithe/Services/LanguageServerToolService.swift b/Sources/Lithe/Services/LanguageServerToolService.swift new file mode 100644 index 00000000..3668de88 --- /dev/null +++ b/Sources/Lithe/Services/LanguageServerToolService.swift @@ -0,0 +1,205 @@ +import Foundation + +struct LanguageServerInstallPlan: Equatable, Sendable { + let homebrewFormula: String? + let officialDownloadURL: URL? + + static func plan(for descriptor: LanguageProviderDescriptor) -> Self { + let installation = descriptor.languageServerInstallation + return Self( + homebrewFormula: installation?.homebrewFormula.flatMap { + isSafeHomebrewFormula($0) ? $0 : nil + }, + officialDownloadURL: installation?.officialDownloadURL.flatMap { + $0.scheme?.lowercased() == "https" && $0.host != nil ? $0 : nil + } + ) + } + + private static func isSafeHomebrewFormula(_ formula: String) -> Bool { + guard !formula.isEmpty, + formula.count <= 200, + !formula.hasPrefix("/"), + !formula.hasSuffix("/"), + !formula.contains("//"), + !formula.contains("..") else { return false } + let allowed = CharacterSet.alphanumerics.union( + CharacterSet(charactersIn: "@+._-/") + ) + return formula.unicodeScalars.allSatisfy { allowed.contains($0) } + } +} + +enum LanguageServerInstallationState: Equatable, Sendable { + case idle + case installing + case installed(String) + case failed(String) +} + +enum LanguageServerToolConfigurationError: LocalizedError, Equatable { + case executableRequired + case executableInvalid(String) + case homebrewUnavailable + case homebrewUnsupported(String) + + var errorDescription: String? { + switch self { + case .executableRequired: + "Choose a language-server executable." + case .executableInvalid(let path): + "The selected language-server path is not executable: \(path)" + case .homebrewUnavailable: + "Homebrew is not installed or is not available to Lithe." + case .homebrewUnsupported(let provider): + "No verified Homebrew formula is configured for \(provider)." + } + } +} + +@MainActor +final class LanguageServerToolService: ObservableObject { + @Published private(set) var customExecutablePaths: [String: String] + @Published private(set) var installationStates: [String: LanguageServerInstallationState] = [:] + + private let runtimeService: ProjectRuntimeService + private let processRunner: any ProcessRunner + private let settingsStore: LanguageServerToolSettingsStore + + init( + runtimeService: ProjectRuntimeService, + processRunner: any ProcessRunner, + store: any KeyValueStore + ) { + self.runtimeService = runtimeService + self.processRunner = processRunner + settingsStore = LanguageServerToolSettingsStore(store: store) + customExecutablePaths = settingsStore.load() + } + + func installPlan(for descriptor: LanguageProviderDescriptor) -> LanguageServerInstallPlan { + LanguageServerInstallPlan.plan(for: descriptor) + } + + func customExecutablePath(for providerID: String) -> String? { + customExecutablePaths[providerID] + } + + func installationState(for providerID: String) -> LanguageServerInstallationState { + installationStates[providerID] ?? .idle + } + + func isHomebrewAvailable() -> Bool { + runtimeService.executableOnPath("brew") != nil + } + + func candidates(for descriptor: LanguageProviderDescriptor) -> [RuntimeToolCandidate] { + var result: [RuntimeToolCandidate] = [] + var seen = Set() + + if let path = customExecutablePath(for: descriptor.id), + let executableURL = runtimeService.executableURL(at: path) { + result.append(RuntimeToolCandidate( + command: descriptor.languageServerLaunch?.executableNames.first ?? descriptor.id, + executableURL: executableURL, + source: .custom, + detail: "Lithe override" + )) + seen.insert(executableURL.path) + } + + for command in descriptor.languageServerLaunch?.executableNames ?? [] { + for candidate in runtimeService.executableCandidates(command) { + guard seen.insert(candidate.executableURL.path).inserted else { continue } + result.append(candidate) + } + } + return result + } + + func executableURL(for descriptor: LanguageProviderDescriptor) -> URL? { + candidates(for: descriptor).first?.executableURL + } + + func setCustomExecutablePath(_ path: String, for providerID: String) throws { + let normalized = (path as NSString) + .expandingTildeInPath + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + throw LanguageServerToolConfigurationError.executableRequired + } + guard let executableURL = runtimeService.executableURL(at: normalized) else { + throw LanguageServerToolConfigurationError.executableInvalid(normalized) + } + customExecutablePaths[providerID] = executableURL.path + settingsStore.save(customExecutablePaths) + } + + func clearCustomExecutablePath(for providerID: String) { + customExecutablePaths[providerID] = nil + settingsStore.save(customExecutablePaths) + } + + func installWithHomebrew(_ descriptor: LanguageProviderDescriptor) async { + let plan = installPlan(for: descriptor) + guard let formula = plan.homebrewFormula else { + installationStates[descriptor.id] = .failed( + LanguageServerToolConfigurationError.homebrewUnsupported(descriptor.displayName) + .localizedDescription + ) + return + } + guard let brewURL = runtimeService.executableOnPath("brew") else { + installationStates[descriptor.id] = .failed( + LanguageServerToolConfigurationError.homebrewUnavailable.localizedDescription + ) + return + } + + installationStates[descriptor.id] = .installing + let runner = processRunner + let request = ProcessRequest( + operationID: "lsp-install-\(descriptor.id)-\(UUID().uuidString)", + executablePath: brewURL.path, + arguments: ["install", formula], + environment: runtimeService.processEnvironment(), + timeoutMilliseconds: 10 * 60 * 1_000 + ) + let result = await Task.detached(priority: .userInitiated) { + runner.run(request) + }.value + + let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + if result.succeeded { + installationStates[descriptor.id] = .installed( + output.isEmpty ? "brew install \(formula) completed." : output + ) + } else { + installationStates[descriptor.id] = .failed( + output.isEmpty ? "brew install \(formula) failed with exit code \(result.exitCode)." : output + ) + } + } +} + +private struct LanguageServerToolSettingsStore { + private static let key = "lithe.language-server-tools.executable-paths" + private let store: any KeyValueStore + + init(store: any KeyValueStore) { + self.store = store + } + + func load() -> [String: String] { + guard let data = store.data(forKey: Self.key), + let value = try? JSONDecoder().decode([String: String].self, from: data) else { + return [:] + } + return value + } + + func save(_ paths: [String: String]) { + guard let data = try? JSONEncoder().encode(paths) else { return } + store.set(data, forKey: Self.key) + } +} diff --git a/Sources/Lithe/Services/ProjectRuntimeService.swift b/Sources/Lithe/Services/ProjectRuntimeService.swift index 8a316138..751f8f65 100644 --- a/Sources/Lithe/Services/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/ProjectRuntimeService.swift @@ -324,6 +324,15 @@ final class ProjectRuntimeService: ObservableObject { executableCandidates(command).first?.executableURL } + func executableURL(at path: String) -> URL? { + let normalized = (path as NSString) + .expandingTildeInPath + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } + let url = URL(fileURLWithPath: normalized).standardizedFileURL + return runtimeLocator.isExecutable(at: url) ? url : nil + } + func mavenExecutable(for project: MavenProject) -> URL? { mavenExecutable(at: project.rootURL) } diff --git a/Tests/LitheTests/LanguageServerToolServiceTests.swift b/Tests/LitheTests/LanguageServerToolServiceTests.swift new file mode 100644 index 00000000..cab201f8 --- /dev/null +++ b/Tests/LitheTests/LanguageServerToolServiceTests.swift @@ -0,0 +1,228 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Language server tool service") +@MainActor +struct LanguageServerToolServiceTests { + @Test + func customExecutablePersistsAndOverridesAutomaticDiscovery() throws { + let customURL = URL(fileURLWithPath: "/custom/bin/gopls") + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/gopls") + let store = LanguageServerToolTestStore() + let runtime = makeRuntime( + executablePaths: [customURL.path, brewURL.path], + candidates: [ + "gopls": [RuntimeToolCandidate( + command: "gopls", + executableURL: brewURL, + source: .homebrew + )] + ], + store: store + ) + let descriptor = goDescriptor() + let service = LanguageServerToolService( + runtimeService: runtime, + processRunner: LanguageServerToolTestProcessRunner(), + store: store + ) + + try service.setCustomExecutablePath(customURL.path, for: descriptor.id) + #expect(service.executableURL(for: descriptor) == customURL) + #expect(service.candidates(for: descriptor).map(\.source) == [.custom, .homebrew]) + + let restored = LanguageServerToolService( + runtimeService: runtime, + processRunner: LanguageServerToolTestProcessRunner(), + store: store + ) + #expect(restored.customExecutablePath(for: descriptor.id) == customURL.path) + restored.clearCustomExecutablePath(for: descriptor.id) + #expect(restored.executableURL(for: descriptor) == brewURL) + } + + @Test + func rejectsNonExecutableCustomPath() { + let store = LanguageServerToolTestStore() + let service = LanguageServerToolService( + runtimeService: makeRuntime(executablePaths: [], candidates: [:], store: store), + processRunner: LanguageServerToolTestProcessRunner(), + store: store + ) + + #expect(throws: LanguageServerToolConfigurationError.executableInvalid("/missing/gopls")) { + try service.setCustomExecutablePath("/missing/gopls", for: "go") + } + } + + @Test + func installsVerifiedFormulaWithArgumentBasedProcessRequest() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let store = LanguageServerToolTestStore() + let runner = LanguageServerToolTestProcessRunner( + result: ProcessResult(output: "installed gopls", exitCode: 0) + ) + let runtime = makeRuntime( + executablePaths: [brewURL.path], + candidates: [ + "brew": [RuntimeToolCandidate( + command: "brew", + executableURL: brewURL, + source: .homebrew + )] + ], + store: store + ) + let service = LanguageServerToolService( + runtimeService: runtime, + processRunner: runner, + store: store + ) + + await service.installWithHomebrew(goDescriptor()) + + let request = try #require(runner.lastRequest) + #expect(request.executablePath == brewURL.path) + #expect(request.arguments == ["install", "gopls"]) + #expect(service.installationState(for: "go") == .installed("installed gopls")) + } + + @Test + func installationCatalogUsesOfficialFallbacks() { + let go = LanguageServerInstallPlan.plan(for: goDescriptor()) + #expect(go.homebrewFormula == "gopls") + #expect(go.officialDownloadURL?.host == "go.dev") + + let swift = LanguageServerInstallPlan.plan(for: LanguageProviderDescriptor( + id: "swift", + displayName: "Swift", + fileExtensions: ["swift"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageServerInstallation: LanguageServerInstallationDescriptor( + homebrewFormula: nil, + officialDownloadURL: URL(string: "https://github.com/swiftlang/sourcekit-lsp") + ) + )) + #expect(swift.homebrewFormula == nil) + #expect(swift.officialDownloadURL?.host == "github.com") + } + + @Test + func installationCatalogRejectsUnsafeExecutableMetadata() { + let descriptor = LanguageProviderDescriptor( + id: "unsafe", + displayName: "Unsafe", + fileExtensions: ["unsafe"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageServerInstallation: LanguageServerInstallationDescriptor( + homebrewFormula: "../../bin/tool", + officialDownloadURL: URL(string: "http://example.com/tool") + ) + ) + + let plan = LanguageServerInstallPlan.plan(for: descriptor) + #expect(plan.homebrewFormula == nil) + #expect(plan.officialDownloadURL == nil) + } + + private func goDescriptor() -> LanguageProviderDescriptor { + LanguageProviderDescriptor( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "go", + languageServerLaunch: LanguageServerLaunchDescriptor( + executableNames: ["gopls"], + arguments: [] + ), + languageServerInstallation: LanguageServerInstallationDescriptor( + homebrewFormula: "gopls", + officialDownloadURL: URL(string: "https://go.dev/gopls/") + ) + ) + } + + private func makeRuntime( + executablePaths: Set, + candidates: [String: [RuntimeToolCandidate]], + store: any KeyValueStore + ) -> ProjectRuntimeService { + ProjectRuntimeService( + runtimeLocator: LanguageServerToolTestRuntimeLocator(executablePaths: executablePaths), + store: store, + toolDiscovery: LanguageServerToolTestDiscovery(candidatesByCommand: candidates) + ) + } +} + +private struct LanguageServerToolTestRuntimeLocator: RuntimeLocator { + let executablePaths: Set + + func environment() -> [String: String] { ["PATH": ""] } + func discover() -> RuntimeDiscoveryResult { RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + func validJavaHome(path _: String) -> URL? { nil } + func javaRuntime(at _: URL) -> JavaRuntimeCandidate? { nil } + func isExecutable(at url: URL) -> Bool { executablePaths.contains(url.standardizedFileURL.path) } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath _: String) -> URL? { nil } + func mavenRuntime(at _: URL) -> MavenRuntimeCandidate? { nil } + func systemJDBExecutable() -> URL? { nil } +} + +private struct LanguageServerToolTestDiscovery: RuntimeToolDiscovery { + let candidatesByCommand: [String: [RuntimeToolCandidate]] + + func candidates( + for command: String, + projectURL _: URL?, + environment _: [String: String] + ) -> [RuntimeToolCandidate] { + candidatesByCommand[command] ?? [] + } + + func guidance( + for command: String, + projectURL _: URL?, + environment _: [String: String] + ) -> RuntimeToolGuidance { + RuntimeToolGuidance( + command: command, + summary: "Missing \(command).", + recovery: "Install it." + ) + } +} + +private final class LanguageServerToolTestProcessRunner: ProcessRunner, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedRequest: ProcessRequest? + + init(result: ProcessResult = ProcessResult(output: "", exitCode: 0)) { + self.result = result + } + + var lastRequest: ProcessRequest? { + lock.withLock { recordedRequest } + } + + func run(_ request: ProcessRequest) -> ProcessResult { + lock.withLock { recordedRequest = request } + return result + } +} + +private final class LanguageServerToolTestStore: KeyValueStore { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} From 2bb72b777f7ceb18ce7101b1ae8ab75256402262 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 09:08:50 +0800 Subject: [PATCH 23/38] Add LSP setup controls --- .../Lithe/Views/LSPControlCenterView.swift | 36 ++ .../Lithe/Views/LanguageServerSetupView.swift | 396 ++++++++++++++++++ Sources/Lithe/Views/WorkbenchView.swift | 18 - docs/architecture/language-tooling.md | 39 +- 4 files changed, 460 insertions(+), 29 deletions(-) create mode 100644 Sources/Lithe/Views/LanguageServerSetupView.swift diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index 56a9d442..68f0d058 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -4,6 +4,7 @@ struct LSPControlCenterView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @State private var selectedProviderID: String? + @State private var isToolSetupPresented = false private let metricColumns = [ GridItem(.flexible(), spacing: 8), @@ -43,6 +44,34 @@ struct LSPControlCenterView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) Spacer(minLength: 0) + Button { + isToolSetupPresented.toggle() + } label: { + LitheIDEAIcon( + resourcePath: "general/gear.svg", + size: 15, + fallbackSystemImage: "gearshape" + ) + } + .litheIconButton() + .help(copy.configureLanguageServers) + .popover(isPresented: $isToolSetupPresented, arrowEdge: .trailing) { + LanguageServerSetupView( + tools: model.languageServerTools, + providers: configurableLanguageServerDescriptors, + initialProviderID: selectedDescriptor?.id, + language: settings.language, + chooseExecutable: { descriptor in + model.chooseLanguageServerExecutable(providerName: descriptor.displayName) + }, + openOfficialDownload: { url in + model.openLanguageServerDownload(url) + }, + configurationChanged: { providerID in + model.languageServerToolConfigurationDidChange(providerID: providerID) + } + ) + } Button { model.isLSPControlCenterVisible = false } label: { @@ -486,6 +515,12 @@ struct LSPControlCenterView: View { } } + private var configurableLanguageServerDescriptors: [LanguageProviderDescriptor] { + model.languageProviderCatalog.descriptors + .filter { $0.capabilities.contains(.languageServer) } + .filter { $0.languageServerLaunch != nil } + } + private var selectedDescriptor: LanguageProviderDescriptor? { if let selectedProviderID, let selected = languageServerDescriptors.first(where: { $0.id == selectedProviderID }) { @@ -645,6 +680,7 @@ private struct LSPControlCenterCopy { var title: String { usesChinese ? "LSP 控制中心" : "LSP Control Center" } var hideControlCenter: String { usesChinese ? "隐藏 LSP 控制中心" : "Hide LSP Control Center" } + var configureLanguageServers: String { usesChinese ? "配置语言服务器" : "Configure language servers" } var currentProject: String { usesChinese ? "当前项目:" : "Current project:" } var lspActive: String { usesChinese ? "LSP 运行中" : "LSP active" } var onDemand: String { usesChinese ? "按需启动" : "On demand" } diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/LanguageServerSetupView.swift new file mode 100644 index 00000000..2b9ecf8a --- /dev/null +++ b/Sources/Lithe/Views/LanguageServerSetupView.swift @@ -0,0 +1,396 @@ +import SwiftUI + +struct LanguageServerSetupView: View { + @ObservedObject var tools: LanguageServerToolService + + let providers: [LanguageProviderDescriptor] + let language: AppLanguage + let chooseExecutable: (LanguageProviderDescriptor) -> URL? + let openOfficialDownload: (URL) -> Void + let configurationChanged: (String) -> Void + + @State private var selectedProviderID: String + @State private var executablePathDraft = "" + @State private var validationMessage: String? + + init( + tools: LanguageServerToolService, + providers: [LanguageProviderDescriptor], + initialProviderID: String?, + language: AppLanguage, + chooseExecutable: @escaping (LanguageProviderDescriptor) -> URL?, + openOfficialDownload: @escaping (URL) -> Void, + configurationChanged: @escaping (String) -> Void + ) { + self.tools = tools + self.providers = providers + self.language = language + self.chooseExecutable = chooseExecutable + self.openOfficialDownload = openOfficialDownload + self.configurationChanged = configurationChanged + let initialID = initialProviderID.flatMap { id in + providers.contains(where: { $0.id == id }) ? id : nil + } ?? providers.first?.id ?? "" + _selectedProviderID = State(initialValue: initialID) + _executablePathDraft = State(initialValue: tools.customExecutablePath(for: initialID) ?? "") + } + + private var copy: LanguageServerSetupCopy { + LanguageServerSetupCopy(language: language) + } + + private var selectedDescriptor: LanguageProviderDescriptor? { + providers.first { $0.id == selectedProviderID } + } + + private var candidates: [RuntimeToolCandidate] { + selectedDescriptor.map(tools.candidates(for:)) ?? [] + } + + private var resolvedExecutable: RuntimeToolCandidate? { + candidates.first + } + + private var installPlan: LanguageServerInstallPlan? { + selectedDescriptor.map(tools.installPlan(for:)) + } + + private var installationState: LanguageServerInstallationState { + selectedDescriptor.map { tools.installationState(for: $0.id) } ?? .idle + } + + var body: some View { + VStack(spacing: 0) { + setupHeader + Divider().overlay(LitheTheme.divider) + + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 14) { + providerPicker + detectionSection + pathSection + installSection + } + .padding(14) + } + .litheScrollViewChrome(hideHorizontal: true) + } + .frame(width: 430, height: 510) + .background(LitheTheme.sidebar) + .onChange(of: selectedProviderID) { _, providerID in + executablePathDraft = tools.customExecutablePath(for: providerID) ?? "" + validationMessage = nil + } + } + + private var setupHeader: some View { + HStack(spacing: 10) { + Image(systemName: "wrench.and.screwdriver") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(LitheTheme.accent) + .frame(width: 28, height: 28) + .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.subtleSelection)) + VStack(alignment: .leading, spacing: 1) { + Text(copy.title) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text(copy.subtitle) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(height: 50) + .background(LitheTheme.toolHeader) + } + + private var providerPicker: some View { + VStack(alignment: .leading, spacing: 6) { + sectionTitle(copy.languageServer) + Picker(copy.languageServer, selection: $selectedProviderID) { + ForEach(providers) { descriptor in + Text(descriptor.displayName).tag(descriptor.id) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var detectionSection: some View { + VStack(alignment: .leading, spacing: 7) { + sectionTitle(copy.detectedExecutable) + HStack(alignment: .top, spacing: 9) { + Circle() + .fill(resolvedExecutable == nil ? LitheTheme.warning : LitheTheme.success) + .frame(width: 8, height: 8) + .padding(.top, 4) + VStack(alignment: .leading, spacing: 3) { + Text(resolvedExecutable == nil ? copy.notFound : copy.ready) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text(resolvedExecutable?.executableURL.path ?? expectedCommands) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(2) + .truncationMode(.middle) + } + Spacer(minLength: 0) + if let source = resolvedExecutable?.source { + Text(source.displayName) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 7) + .frame(height: 20) + .background(Capsule().fill(LitheTheme.raised)) + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 7).fill(LitheTheme.raised.opacity(0.55))) + + ForEach(Array(candidates.dropFirst().prefix(2))) { candidate in + HStack(spacing: 7) { + Image(systemName: "arrow.turn.down.right") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.secondaryText) + Text(candidate.executableURL.path) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(candidate.source.displayName) + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + } + + private var pathSection: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + sectionTitle(copy.executablePath) + Spacer(minLength: 0) + Button(copy.useAutomatic) { + clearOverride() + } + .buttonStyle(.plain) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.accent) + .disabled(tools.customExecutablePath(for: selectedProviderID) == nil) + } + + HStack(spacing: 7) { + TextField(copy.pathPlaceholder, text: $executablePathDraft) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + Button { + browseForExecutable() + } label: { + Image(systemName: "folder") + } + .litheIconButton() + .help(copy.chooseExecutable) + Button(copy.savePath) { + savePath() + } + .buttonStyle(LitheSecondaryButtonStyle()) + .disabled(executablePathDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + Text(validationMessage ?? copy.pathHint) + .font(.system(size: 10.5)) + .foregroundStyle(validationMessage == nil ? LitheTheme.secondaryText : LitheTheme.error) + .lineLimit(2) + } + } + + private var installSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle(copy.installation) + Text(copy.installationHint) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + + HStack(spacing: 8) { + Button { + installWithHomebrew() + } label: { + HStack(spacing: 7) { + if case .installing = installationState { + ProgressView().controlSize(.small) + } else { + Image(systemName: "shippingbox") + } + Text(homebrewButtonTitle) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(LithePrimaryButtonStyle()) + .disabled(!canInstallWithHomebrew) + + Button { + guard let url = installPlan?.officialDownloadURL else { return } + openOfficialDownload(url) + } label: { + Label(copy.officialDownload, systemImage: "arrow.up.right.square") + .frame(maxWidth: .infinity) + } + .buttonStyle(LitheSecondaryButtonStyle()) + .disabled(installPlan?.officialDownloadURL == nil) + } + + installationMessage + } + } + + @ViewBuilder + private var installationMessage: some View { + switch installationState { + case .idle: + if installPlan?.homebrewFormula != nil, !tools.isHomebrewAvailable() { + statusMessage(copy.homebrewUnavailable, color: LitheTheme.warning) + } + case .installing: + statusMessage(copy.installing, color: LitheTheme.accent) + case .installed(let output): + statusMessage(copy.installComplete(output), color: LitheTheme.success) + case .failed(let message): + statusMessage(message, color: LitheTheme.error) + } + } + + private func statusMessage(_ message: String, color: Color) -> some View { + Text(message) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(color) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.raised.opacity(0.55))) + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + } + + private var expectedCommands: String { + guard let commands = selectedDescriptor?.languageServerLaunch?.executableNames, + !commands.isEmpty else { return copy.noLaunchCommand } + return copy.expectedCommands(commands.joined(separator: ", ")) + } + + private var canInstallWithHomebrew: Bool { + guard installPlan?.homebrewFormula != nil, + tools.isHomebrewAvailable() else { return false } + if case .installing = installationState { return false } + return true + } + + private var homebrewButtonTitle: String { + guard let formula = installPlan?.homebrewFormula else { return copy.noHomebrewFormula } + return copy.installWithHomebrew(formula) + } + + private func browseForExecutable() { + guard let descriptor = selectedDescriptor, + let url = chooseExecutable(descriptor) else { return } + executablePathDraft = url.path + savePath() + } + + private func savePath() { + guard let descriptor = selectedDescriptor else { return } + do { + try tools.setCustomExecutablePath(executablePathDraft, for: descriptor.id) + executablePathDraft = tools.customExecutablePath(for: descriptor.id) ?? executablePathDraft + validationMessage = nil + configurationChanged(descriptor.id) + } catch { + validationMessage = if let configurationError = error as? LanguageServerToolConfigurationError { + copy.message(for: configurationError) + } else { + error.localizedDescription + } + } + } + + private func clearOverride() { + guard let descriptor = selectedDescriptor else { return } + tools.clearCustomExecutablePath(for: descriptor.id) + executablePathDraft = "" + validationMessage = nil + configurationChanged(descriptor.id) + } + + private func installWithHomebrew() { + guard let descriptor = selectedDescriptor else { return } + Task { + await tools.installWithHomebrew(descriptor) + if case .installed = tools.installationState(for: descriptor.id) { + configurationChanged(descriptor.id) + } + } + } +} + +private struct LanguageServerSetupCopy { + let language: AppLanguage + + private var usesChinese: Bool { language == .simplifiedChinese } + + var title: String { usesChinese ? "语言服务器工具" : "Language Server Tools" } + var subtitle: String { usesChinese ? "安装、探测并指定 LSP 可执行文件" : "Install, detect, and select LSP executables" } + var languageServer: String { usesChinese ? "语言服务器" : "Language server" } + var detectedExecutable: String { usesChinese ? "当前解析结果" : "Resolved executable" } + var ready: String { usesChinese ? "可用" : "Ready" } + var notFound: String { usesChinese ? "未找到可执行文件" : "Executable not found" } + var executablePath: String { usesChinese ? "自定义路径" : "Custom path" } + var useAutomatic: String { usesChinese ? "恢复自动探测" : "Use automatic detection" } + var pathPlaceholder: String { usesChinese ? "选择或输入绝对路径" : "Choose or enter an absolute path" } + var chooseExecutable: String { usesChinese ? "选择可执行文件" : "Choose executable" } + var savePath: String { usesChinese ? "保存" : "Save" } + var pathHint: String { usesChinese ? "保存后,下一次启动该 LSP 时使用此路径。" : "The next LSP session will use this path." } + var installation: String { usesChinese ? "安装" : "Installation" } + var installationHint: String { + usesChinese + ? "优先使用 Homebrew;没有可用 formula 或 Homebrew 时,从官方页面下载安装。" + : "Homebrew is preferred. Use the official download when Homebrew or a formula is unavailable." + } + var officialDownload: String { usesChinese ? "官方下载" : "Official download" } + var homebrewUnavailable: String { usesChinese ? "未找到 Homebrew,请使用官方下载或手动指定路径。" : "Homebrew was not found. Use the official download or choose an executable." } + var installing: String { usesChinese ? "Homebrew 正在安装…" : "Installing with Homebrew..." } + var noHomebrewFormula: String { usesChinese ? "无 Brew formula" : "No Brew formula" } + var noLaunchCommand: String { usesChinese ? "Provider 未配置启动命令" : "No launch command is configured" } + + func expectedCommands(_ commands: String) -> String { + usesChinese ? "等待探测:\(commands)" : "Expected: \(commands)" + } + + func installWithHomebrew(_ formula: String) -> String { + usesChinese ? "Brew 安装 \(formula)" : "Install \(formula)" + } + + func installComplete(_ output: String) -> String { + let firstLine = output.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? output + return usesChinese ? "安装完成:\(firstLine)" : "Installed: \(firstLine)" + } + + func message(for error: LanguageServerToolConfigurationError) -> String { + switch error { + case .executableRequired: + usesChinese ? "请选择语言服务器可执行文件。" : error.localizedDescription + case .executableInvalid(let path): + usesChinese ? "该路径不是可执行文件:\(path)" : error.localizedDescription + case .homebrewUnavailable: + usesChinese ? "Lithe 无法找到 Homebrew。" : error.localizedDescription + case .homebrewUnsupported(let provider): + usesChinese ? "\(provider) 没有已验证的 Homebrew formula。" : error.localizedDescription + } + } +} diff --git a/Sources/Lithe/Views/WorkbenchView.swift b/Sources/Lithe/Views/WorkbenchView.swift index adcf92c5..63781def 100644 --- a/Sources/Lithe/Views/WorkbenchView.swift +++ b/Sources/Lithe/Views/WorkbenchView.swift @@ -17,7 +17,6 @@ struct WorkbenchView: View { @State private var isCheckoutRevisionPresented = false @State private var pendingTopBarPushReference: GitReference? @State private var isRunConfigurationPickerPresented = false - @State private var isRunConfigurationEditorPresented = false @State private var isNewRunConfigurationPresented = false @State private var isProjectSwitcherPresented = false @State private var isMemoryUsagePopoverPresented = false @@ -681,23 +680,6 @@ struct WorkbenchView: View { ) } - Button { - isRunConfigurationEditorPresented = true - } label: { - LitheIDEAIcon(resourcePath: "general/gear.svg", size: 15, fallbackSystemImage: "gearshape") - } - .litheIconButton() - .help("Edit run configuration") - .disabled(runFeature.selectedConfiguration == nil || runFeature.configurationStatus != .ready) - .popover(isPresented: $isRunConfigurationEditorPresented, arrowEdge: .bottom) { - if let configuration = runFeature.selectedConfiguration { - RunConfigurationEditorView( - feature: runFeature, - configuration: configuration - ) - } - } - Button { if runFeature.isRunning { model.stopSelectedRun() diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 716b9149..54624c3f 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -62,17 +62,26 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 ## Catalog 与工具发现 内置 provider catalog 位于 -[`rust/lithe-core/resources/lsp/language-providers.json`](../../rust/lithe-core/resources/lsp/language-providers.json)。项目可以通过 `.lithe/lsp/language-providers.json` 按 `id` 覆盖内置字段、添加 provider,或使用 `disabled: true` 禁用 provider: +[`rust/lithe-core/resources/lsp/language-providers.json`](../../rust/lithe-core/resources/lsp/language-providers.json)。项目可以通过 `.lithe/lsp/language-providers.json` 按 `id` 覆盖内置字段、添加 provider,或使用 `disabled: true` 禁用 provider。配置格式由 [`docs/reference/language-providers.schema.json`](../reference/language-providers.schema.json) 定义: ```json { - "version": 1, + "$schema": "../../docs/reference/language-providers.schema.json", + "version": 2, "providers": [ { "id": "go", "languageServerLaunch": { "executableNames": ["gopls-custom", "gopls"], - "arguments": [] + "arguments": [], + "environment": { + "GOTOOLCHAIN": "auto" + }, + "initializationOptions": {} + }, + "languageServerInstallation": { + "homebrewFormula": "gopls", + "officialDownloadURL": "https://go.dev/gopls/" } }, { @@ -91,7 +100,13 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 } ``` -`executableNames` 按顺序尝试。macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 +`executableNames` 按顺序尝试,`environment` 覆盖 Lithe 进程环境中的同名键,`initializationOptions` 原样进入 LSP `initialize` 参数。catalog 更新后,session manager 会丢弃 descriptor 已变化的旧会话和 runtime,并由 runtime factory 根据新 descriptor 延迟创建 runtime;因此项目新增 provider 或覆盖启动命令不再受应用启动时的内置 runtime 列表限制。 + +macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 + +LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provider 的可执行文件覆盖路径。session 创建时先验证并使用该路径,路径失效时继续使用 catalog 候选进行自动探测。Homebrew formula 和官方兜底地址都来自 `languageServerInstallation`,Swift 不维护 provider ID 映射。安装仍由平台层以参数数组直接执行 `brew install`,不经过 shell;没有 Homebrew/formula 时只打开对应项目的 HTTPS 官方发布或安装页面,避免用一套不安全的通用解压逻辑处理不同项目的签名和包结构。 + +项目配置是可执行工具配置,只有打开受信任项目时才应启用。JSON 可以声明 executable name 和参数,但不能声明 shell、任意安装命令或关闭路径/URL 校验;进程创建、超时、可执行文件验证、Homebrew 调用方式和 HTTPS 限制仍属于平台安全边界。 ## LSP 会话与兼容性 @@ -114,19 +129,21 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 - 只支持 stdio transport,尚无 socket/TCP 或服务器自定义握手 adapter。 - session 当前以 provider ID 和单个 workspace root 为单位,尚无 multi-root session。 -- `workspace/applyEdit`、自定义初始化参数和服务器私有命令没有通用处理层;客户端不会宣称未实现的 `applyEdit` 能力。 +- `workspace/applyEdit` 和服务器私有 request 没有通用处理层;客户端不会宣称未实现的 `applyEdit` 能力。 +- 编辑器尚未实现 snippet tabstop 会话,因此 initialize 明确声明 `snippetSupport: false`;completion 中的 snippet 只会降级成纯文本。 - 文档同步当前发送全量文本,没有按服务器类型实现增量 diff。 - catalog 描述的是“可尝试启动的工具”;最终功能必须以运行时服务器 capability 为准。 -- project config 目前是受信任的项目配置,只接受 executable name 和参数,不执行 shell 命令。 +- project config 是受信任的项目配置,只接受 schema 中的 typed 字段,不执行 shell 命令。 ## 接入新 LSP 的检查清单 1. 在 catalog 中定义稳定 `id`、文件匹配规则、`languageId`、候选 executable 和参数。 -2. 确认服务器支持 stdio 和标准 `Content-Length` framing。 -3. 不在 UI 或 manager 中按语言写分支;服务器差异应进入 descriptor 或独立 adapter。 -4. 用 initialize 响应验证 capability,不把 catalog 的 `languageServer` 标记当成 feature 支持证明。 -5. 至少测试 initialize、didOpen/change/close、一个功能请求、shutdown/exit 和异常退出。 -6. 包含空结果、服务器 error、UTF-16、带空格/非 ASCII 文件 URI,以及启动即输出的场景。 +2. 需要安装入口时定义 `languageServerInstallation`;不要在 Swift UI 中增加 provider ID 分支。 +3. 确认服务器支持 stdio 和标准 `Content-Length` framing。 +4. 不在 UI 或 manager 中按语言写分支;服务器差异应进入 descriptor 或独立 adapter。 +5. 用 initialize 响应验证 capability,不把 catalog 的 `languageServer` 标记当成 feature 支持证明。 +6. 至少测试 initialize、didOpen/change/close、一个功能请求、shutdown/exit 和异常退出。 +7. 包含空结果、服务器 error、UTF-16、带空格/非 ASCII 文件 URI,以及启动即输出的场景。 ## 真实 gopls 验证 From eedd014cc5395707aba7525c259ec46ba9c1c211 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 09:42:53 +0800 Subject: [PATCH 24/38] Add stateful Rust LSP session host --- Sources/Lithe/Core/RustCoreBridge.swift | 215 +++++++++++++++ rust/lithe-core/src/command.rs | 6 + rust/lithe-core/src/lib.rs | 1 + rust/lithe-core/src/lsp_host.rs | 326 +++++++++++++++++++++++ rust/lithe-core/src/runtime.rs | 17 ++ scripts/RustCoreBridgeVerification.swift | 66 +++++ 6 files changed, 631 insertions(+) create mode 100644 rust/lithe-core/src/lsp_host.rs diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 54d19564..90a5f77d 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1027,6 +1027,13 @@ struct RustCoreBridge: Sendable { let events: [LspClientEventPayload] } + struct LspSessionResponsePayload: Decodable, Sendable { + let sessionId: String + let serverCapabilities: [String] + let messages: [String] + let events: [LspClientEventPayload] + } + struct LspClientEventPayload: Decodable, Sendable { let kind: String let requestId: String? @@ -1147,6 +1154,64 @@ struct RustCoreBridge: Sendable { let message: String } + private struct LspSessionCommandRequest: Encodable { + let action: String + let sessionId: String? + let rootUri: String? + let processId: Int? + let initializationOptions: ToolingJSONValue? + let uri: String? + let languageId: String? + let text: String? + let method: String? + let position: LspTextEditsRequest.TextEdit.Range.Position? + let newName: String? + let range: LspTextEditsRequest.TextEdit.Range? + let diagnostics: [LspClientDiagnosticRequest] + let completionItem: LspClientCompletionItemRequest? + let codeAction: LspClientCodeActionRequest? + let command: LspClientCommandRequest? + let message: String? + + init( + action: String, + sessionId: String? = nil, + rootUri: String? = nil, + processId: Int? = nil, + initializationOptions: ToolingJSONValue? = nil, + uri: String? = nil, + languageId: String? = nil, + text: String? = nil, + method: String? = nil, + position: LspTextEditsRequest.TextEdit.Range.Position? = nil, + newName: String? = nil, + range: LspTextEditsRequest.TextEdit.Range? = nil, + diagnostics: [LspClientDiagnosticRequest] = [], + completionItem: LspClientCompletionItemRequest? = nil, + codeAction: LspClientCodeActionRequest? = nil, + command: LspClientCommandRequest? = nil, + message: String? = nil + ) { + self.action = action + self.sessionId = sessionId + self.rootUri = rootUri + self.processId = processId + self.initializationOptions = initializationOptions + self.uri = uri + self.languageId = languageId + self.text = text + self.method = method + self.position = position + self.newName = newName + self.range = range + self.diagnostics = diagnostics + self.completionItem = completionItem + self.codeAction = codeAction + self.command = command + self.message = message + } + } + struct LspFramePayload: Decodable, Sendable { let frame: String } @@ -2101,6 +2166,129 @@ struct RustCoreBridge: Sendable { lspClientInitialize(rootURL: rootURL, initializationOptions: nil) } + func lspSessionCreate( + rootURL: URL, + initializationOptions: ToolingJSONValue? + ) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest( + action: "create", + rootUri: rootURL.standardizedFileURL.absoluteString, + processId: Int(ProcessInfo.processInfo.processIdentifier), + initializationOptions: initializationOptions + ) + ) + } + + func lspSessionOpenDocument( + sessionID: String, + fileURL: URL, + languageID: String, + text: String + ) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest( + action: "openDocument", + sessionId: sessionID, + uri: fileURL.standardizedFileURL.absoluteString, + languageId: languageID, + text: text + ) + ) + } + + func lspSessionChangeDocument( + sessionID: String, + fileURL: URL, + text: String + ) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest( + action: "changeDocument", + sessionId: sessionID, + uri: fileURL.standardizedFileURL.absoluteString, + text: text + ) + ) + } + + func lspSessionCloseDocument( + sessionID: String, + fileURL: URL + ) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest( + action: "closeDocument", + sessionId: sessionID, + uri: fileURL.standardizedFileURL.absoluteString + ) + ) + } + + func lspSessionShutdown(sessionID: String) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest(action: "shutdown", sessionId: sessionID) + ) + } + + func lspSessionRequest( + sessionID: String, + fileURL: URL, + method: String, + position: LanguageServerPosition? = nil, + newName: String? = nil, + range: LanguageServerRange? = nil, + diagnostics: [LanguageServerDiagnostic] = [], + completionItem: LanguageServerCompletionItem? = nil, + codeAction: LanguageServerCodeAction? = nil, + command: LanguageServerCommand? = nil + ) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest( + action: "request", + sessionId: sessionID, + uri: fileURL.standardizedFileURL.absoluteString, + method: method, + position: position.map { + .init(line: $0.line, utf16Column: $0.utf16Column) + }, + newName: newName, + range: range.map(Self.makeRangeRequest), + diagnostics: diagnostics.map(Self.makeDiagnosticRequest), + completionItem: completionItem.map(Self.makeCompletionItemRequest), + codeAction: codeAction.map(Self.makeCodeActionRequest), + command: command.map(Self.makeCommandRequest) + ) + ) + } + + func lspSessionApplyServerMessage( + sessionID: String, + message: String + ) -> LspSessionResponsePayload? { + execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest( + action: "applyServerMessage", + sessionId: sessionID, + message: message + ) + ) + } + + func lspSessionDestroy(sessionID: String) { + let _: LspSessionResponsePayload? = execute( + command: "lsp.sessionExecute", + payload: LspSessionCommandRequest(action: "destroy", sessionId: sessionID) + ) + } + func lspClientInitialize( rootURL: URL, initializationOptions: ToolingJSONValue? @@ -2232,6 +2420,33 @@ struct RustCoreBridge: Sendable { ) } + private static func makeRangeRequest( + _ range: LanguageServerRange + ) -> LspTextEditsRequest.TextEdit.Range { + .init( + start: .init( + line: range.start.line, + utf16Column: range.start.utf16Column + ), + end: .init( + line: range.end.line, + utf16Column: range.end.utf16Column + ) + ) + } + + private static func makeDiagnosticRequest( + _ diagnostic: LanguageServerDiagnostic + ) -> LspClientDiagnosticRequest { + LspClientDiagnosticRequest( + range: makeRangeRequest(diagnostic.range), + severity: diagnostic.severity, + message: diagnostic.message, + source: diagnostic.source, + code: diagnostic.code + ) + } + private static func makeTextEditRequest( _ edit: LanguageServerTextEdit ) -> LspClientTextEditRequest { diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index 53f50f13..aa795ca3 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -43,6 +43,7 @@ pub enum CoreCommand { LspClientShutdown, LspClientRequest, LspClientApplyServerMessage, + LspSessionExecute, LspFrameMessage, LspParseServerMessages, JavaRunConfigurations, @@ -104,6 +105,7 @@ impl CoreCommand { "lsp.clientShutdown" => Some(Self::LspClientShutdown), "lsp.clientRequest" => Some(Self::LspClientRequest), "lsp.clientApplyServerMessage" => Some(Self::LspClientApplyServerMessage), + "lsp.sessionExecute" => Some(Self::LspSessionExecute), "lsp.frameMessage" => Some(Self::LspFrameMessage), "lsp.parseServerMessages" => Some(Self::LspParseServerMessages), "java.runConfigurations" => Some(Self::JavaRunConfigurations), @@ -153,5 +155,9 @@ mod tests { CoreCommand::parse("lsp.clientShutdown"), Some(CoreCommand::LspClientShutdown) )); + assert!(matches!( + CoreCommand::parse("lsp.sessionExecute"), + Some(CoreCommand::LspSessionExecute) + )); } } diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 082c016d..75a6c2dc 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -8,6 +8,7 @@ mod git; mod history; mod java; mod lsp; +mod lsp_host; mod markdown; mod maven; mod model; diff --git a/rust/lithe-core/src/lsp_host.rs b/rust/lithe-core/src/lsp_host.rs new file mode 100644 index 00000000..08fe6b7d --- /dev/null +++ b/rust/lithe-core/src/lsp_host.rs @@ -0,0 +1,326 @@ +use crate::error::{CoreError, ErrorCode}; +use crate::lsp::{ + self, ClientApplyServerMessageRequest, ClientChangeDocumentRequest, ClientCloseDocumentRequest, + ClientFeatureRequest, ClientInitializeRequest, ClientOpenDocumentRequest, + ClientShutdownRequest, LspClientDiagnostic, LspClientEvent, LspClientState, LspPosition, + LspRange, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +static HOST: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LspSessionAction { + Create, + OpenDocument, + ChangeDocument, + CloseDocument, + Shutdown, + Request, + ApplyServerMessage, + Destroy, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspSessionCommandRequest { + pub action: LspSessionAction, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub root_uri: Option, + #[serde(default)] + pub process_id: Option, + #[serde(default)] + pub initialization_options: Option, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub language_id: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub method: Option, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, + #[serde(default)] + pub code_action: Option, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspSessionResponse { + pub session_id: String, + pub server_capabilities: Vec, + pub messages: Vec, + pub events: Vec, +} + +pub fn execute(request: LspSessionCommandRequest) -> Result { + HOST.get_or_init(LspHost::new).execute(request) +} + +struct LspHost { + next_session_id: AtomicU64, + sessions: Mutex>>>, +} + +impl LspHost { + fn new() -> Self { + Self { + next_session_id: AtomicU64::new(1), + sessions: Mutex::new(BTreeMap::new()), + } + } + + fn execute(&self, request: LspSessionCommandRequest) -> Result { + if matches!(request.action, LspSessionAction::Create) { + return self.create(request); + } + let session_id = required(request.session_id.clone(), "sessionId")?; + if matches!(request.action, LspSessionAction::Destroy) { + let mut sessions = self.lock_sessions()?; + if sessions.remove(&session_id).is_none() { + return Err(missing_session(&session_id)); + } + return Ok(LspSessionResponse { + session_id, + server_capabilities: Vec::new(), + messages: Vec::new(), + events: Vec::new(), + }); + } + + let session = self.session(&session_id)?; + let mut session_state = Self::lock_session(&session)?; + let state = session_state.clone(); + let response = match request.action { + LspSessionAction::OpenDocument => { + lsp::client_open_document(ClientOpenDocumentRequest { + state, + uri: required(request.uri, "uri")?, + language_id: required(request.language_id, "languageId")?, + text: required(request.text, "text")?, + }) + } + LspSessionAction::ChangeDocument => { + lsp::client_change_document(ClientChangeDocumentRequest { + state, + uri: required(request.uri, "uri")?, + text: required(request.text, "text")?, + }) + } + LspSessionAction::CloseDocument => { + lsp::client_close_document(ClientCloseDocumentRequest { + state, + uri: required(request.uri, "uri")?, + }) + } + LspSessionAction::Shutdown => lsp::client_shutdown(ClientShutdownRequest { state }), + LspSessionAction::Request => lsp::client_feature_request(ClientFeatureRequest { + state, + uri: required(request.uri, "uri")?, + method: required(request.method, "method")?, + position: request.position, + new_name: request.new_name, + range: request.range, + diagnostics: request.diagnostics, + completion_item: request.completion_item, + code_action: request.code_action, + command: request.command, + }), + LspSessionAction::ApplyServerMessage => { + lsp::client_apply_server_message(ClientApplyServerMessageRequest { + state, + message: required(request.message, "message")?, + }) + } + LspSessionAction::Create | LspSessionAction::Destroy => unreachable!(), + }?; + let server_capabilities = response.state.server_capabilities.clone(); + *session_state = response.state; + Ok(LspSessionResponse { + session_id, + server_capabilities, + messages: response.messages, + events: response.events, + }) + } + + fn create(&self, request: LspSessionCommandRequest) -> Result { + let response = lsp::client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: required(request.root_uri, "rootUri")?, + process_id: request.process_id, + initialization_options: request.initialization_options, + })?; + let session_id = self + .next_session_id + .fetch_add(1, Ordering::Relaxed) + .to_string(); + let server_capabilities = response.state.server_capabilities.clone(); + self.lock_sessions()? + .insert(session_id.clone(), Arc::new(Mutex::new(response.state))); + Ok(LspSessionResponse { + session_id, + server_capabilities, + messages: response.messages, + events: response.events, + }) + } + + fn session(&self, session_id: &str) -> Result>, CoreError> { + self.lock_sessions()? + .get(session_id) + .cloned() + .ok_or_else(|| missing_session(session_id)) + } + + fn lock_sessions( + &self, + ) -> Result>>>, CoreError> { + self.sessions.lock().map_err(|_| { + CoreError::new(ErrorCode::Unknown, "LSP session registry lock is poisoned") + }) + } + + fn lock_session( + session: &Mutex, + ) -> Result, CoreError> { + session + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "LSP session lock is poisoned")) + } +} + +fn required(value: Option, field: &str) -> Result { + value.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Missing LSP session request field", + ) + .with_details(field) + }) +} + +fn missing_session(session_id: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Unknown LSP session handle").with_details(session_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request(action: LspSessionAction) -> LspSessionCommandRequest { + serde_json::from_value(json!({ "action": action_name(action) })).unwrap() + } + + fn action_name(action: LspSessionAction) -> &'static str { + match action { + LspSessionAction::Create => "create", + LspSessionAction::OpenDocument => "openDocument", + LspSessionAction::ChangeDocument => "changeDocument", + LspSessionAction::CloseDocument => "closeDocument", + LspSessionAction::Shutdown => "shutdown", + LspSessionAction::Request => "request", + LspSessionAction::ApplyServerMessage => "applyServerMessage", + LspSessionAction::Destroy => "destroy", + } + } + + #[test] + fn host_owns_state_across_document_changes() { + let host = LspHost::new(); + let mut create = request(LspSessionAction::Create); + create.root_uri = Some("file:///tmp/project".to_string()); + let created = host.execute(create).unwrap(); + + let mut initialized = request(LspSessionAction::ApplyServerMessage); + initialized.session_id = Some(created.session_id.clone()); + initialized.message = Some( + json!({ + "jsonrpc": "2.0", + "id": "1", + "result": { "capabilities": { "completionProvider": {} } } + }) + .to_string(), + ); + let initialized = host.execute(initialized).unwrap(); + assert_eq!(initialized.server_capabilities, vec!["completion"]); + + let mut open = request(LspSessionAction::OpenDocument); + open.session_id = Some(created.session_id.clone()); + open.uri = Some("file:///tmp/project/main.go".to_string()); + open.language_id = Some("go".to_string()); + open.text = Some("package main".to_string()); + assert_eq!(host.execute(open).unwrap().messages.len(), 1); + + let mut change = request(LspSessionAction::ChangeDocument); + change.session_id = Some(created.session_id.clone()); + change.uri = Some("file:///tmp/project/main.go".to_string()); + change.text = Some("package main\nfunc main() {}".to_string()); + assert_eq!(host.execute(change).unwrap().messages.len(), 1); + + let mut destroy = request(LspSessionAction::Destroy); + destroy.session_id = Some(created.session_id.clone()); + host.execute(destroy).unwrap(); + + let mut stale = request(LspSessionAction::Shutdown); + stale.session_id = Some(created.session_id); + assert!(host.execute(stale).is_err()); + } + + #[test] + fn host_isolates_open_documents_between_sessions() { + let host = LspHost::new(); + let mut first_create = request(LspSessionAction::Create); + first_create.root_uri = Some("file:///tmp/first".to_string()); + let first = host.execute(first_create).unwrap(); + let mut second_create = request(LspSessionAction::Create); + second_create.root_uri = Some("file:///tmp/second".to_string()); + let second = host.execute(second_create).unwrap(); + + let mut open = request(LspSessionAction::OpenDocument); + open.session_id = Some(first.session_id); + open.uri = Some("file:///tmp/first/main.go".to_string()); + open.language_id = Some("go".to_string()); + open.text = Some("package main".to_string()); + host.execute(open).unwrap(); + + let mut change = request(LspSessionAction::ChangeDocument); + change.session_id = Some(second.session_id); + change.uri = Some("file:///tmp/first/main.go".to_string()); + change.text = Some("package changed".to_string()); + assert!(host.execute(change).is_err()); + } + + #[test] + fn session_requests_reject_unknown_fields() { + let error = serde_json::from_value::(json!({ + "action": "create", + "rootUri": "file:///tmp/project", + "unexpected": true + })) + .unwrap_err(); + + assert!(error.to_string().contains("unknown field `unexpected`")); + } +} diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 5539d579..d07cce55 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -458,6 +458,23 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspSessionExecute => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP session request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp_host::execute) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP session response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspFrameMessage => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index 4d0e0c12..395a8f82 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -181,3 +181,69 @@ guard let clientApplyData = clientApplyResponse.data(using: .utf8), } print("Rust Core LSP client bridge passed") + +let sessionCreateRequest = """ +{"id":"lsp-session-create-test","command":"lsp.sessionExecute","payload":{"action":"create","rootUri":"file:///tmp/project","processId":42}} +""" +guard let sessionCreatePointer = sessionCreateRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP session create bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(sessionCreatePointer) } + +let sessionCreateResponse = String(cString: sessionCreatePointer) +guard let sessionCreateData = sessionCreateResponse.data(using: .utf8), + let sessionCreateEnvelope = try? JSONSerialization.jsonObject(with: sessionCreateData) as? [String: Any], + let sessionCreatePayload = sessionCreateEnvelope["data"] as? [String: Any], + let sessionID = sessionCreatePayload["sessionId"] as? String, + sessionCreatePayload["state"] == nil else { + fputs("Unexpected Rust Core LSP session create response: \(sessionCreateResponse)\n", stderr) + exit(1) +} + +let sessionApplyRequestData = try JSONSerialization.data(withJSONObject: [ + "id": "lsp-session-apply-test", + "command": "lsp.sessionExecute", + "payload": [ + "action": "applyServerMessage", + "sessionId": sessionID, + "message": serverMessage + ] +]) +let sessionApplyRequest = String(data: sessionApplyRequestData, encoding: .utf8)! +guard let sessionApplyPointer = sessionApplyRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP session apply bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(sessionApplyPointer) } + +let sessionApplyResponse = String(cString: sessionApplyPointer) +guard let sessionApplyData = sessionApplyResponse.data(using: .utf8), + let sessionApplyEnvelope = try? JSONSerialization.jsonObject(with: sessionApplyData) as? [String: Any], + let sessionApplyPayload = sessionApplyEnvelope["data"] as? [String: Any], + sessionApplyPayload["state"] == nil, + let sessionCapabilities = sessionApplyPayload["serverCapabilities"] as? [String], + sessionCapabilities.contains("definition"), + sessionCapabilities.contains("completionResolve") else { + fputs("Unexpected Rust Core LSP session apply response: \(sessionApplyResponse)\n", stderr) + exit(1) +} + +let sessionDestroyRequest = """ +{"id":"lsp-session-destroy-test","command":"lsp.sessionExecute","payload":{"action":"destroy","sessionId":"\(sessionID)"}} +""" +guard let sessionDestroyPointer = sessionDestroyRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP session destroy bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(sessionDestroyPointer) } + +let sessionDestroyResponse = String(cString: sessionDestroyPointer) +guard let sessionDestroyData = sessionDestroyResponse.data(using: .utf8), + let sessionDestroyEnvelope = try? JSONSerialization.jsonObject(with: sessionDestroyData) as? [String: Any], + sessionDestroyEnvelope["ok"] as? Bool == true else { + fputs("Unexpected Rust Core LSP session destroy response: \(sessionDestroyResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP session handle bridge passed") From 4bbcdc276176457e74fcce250d1dd727f994ef1c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 09:43:20 +0800 Subject: [PATCH 25/38] Move stdio LSP sessions onto Rust handles --- .../Services/StdioLanguageServerSession.swift | 237 ++++++++++++++---- .../RunConfigurationIntegrationTests.swift | 127 +++++++++- docs/architecture/language-tooling.md | 4 +- 3 files changed, 316 insertions(+), 52 deletions(-) diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index 54523487..eef1cacd 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -49,6 +49,48 @@ protocol LspClientCore: Sendable { extension RustCoreBridge: LspClientCore {} +protocol LspSessionCore: LspClientCore { + func lspSessionCreate( + rootURL: URL, + initializationOptions: ToolingJSONValue? + ) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionOpenDocument( + sessionID: String, + fileURL: URL, + languageID: String, + text: String + ) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionChangeDocument( + sessionID: String, + fileURL: URL, + text: String + ) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionCloseDocument( + sessionID: String, + fileURL: URL + ) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionShutdown(sessionID: String) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionRequest( + sessionID: String, + fileURL: URL, + method: String, + position: LanguageServerPosition?, + newName: String?, + range: LanguageServerRange?, + diagnostics: [LanguageServerDiagnostic], + completionItem: LanguageServerCompletionItem?, + codeAction: LanguageServerCodeAction?, + command: LanguageServerCommand? + ) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionApplyServerMessage( + sessionID: String, + message: String + ) -> RustCoreBridge.LspSessionResponsePayload? + func lspSessionDestroy(sessionID: String) +} + +extension RustCoreBridge: LspSessionCore {} + extension LspClientCore { func lspClientInitialize( rootURL: URL, @@ -75,7 +117,8 @@ final class StdioLanguageServerSession: LanguageServerSession { private let initializationOptions: ToolingJSONValue? private let process: any RawProcessSession private let core: any LspClientCore - private var state: ToolingJSONValue? + private var legacyState: ToolingJSONValue? + private var sessionID: String? private var readBuffer = Data() private var openedDocumentURIs: Set = [] private var pendingDocuments: [String: PendingDocument] = [:] @@ -121,15 +164,23 @@ final class StdioLanguageServerSession: LanguageServerSession { environment: environment, keepsStandardInputOpen: true )) - guard let response = core.lspClientInitialize( + if let sessionCore = core as? any LspSessionCore, + let response = sessionCore.lspSessionCreate( + rootURL: rootURL, + initializationOptions: initializationOptions + ) { + sessionID = response.sessionId + apply(response) + } else if let response = core.lspClientInitialize( rootURL: rootURL, initializationOptions: initializationOptions - ) else { return } - apply(response) + ) { + apply(response) + } } func synchronize(fileURL: URL, text: String, languageID: String) throws { - guard let state else { return } + guard sessionID != nil || legacyState != nil else { return } let standardizedURL = fileURL.standardizedFileURL let uri = standardizedURL.absoluteString guard isInitialized else { @@ -140,32 +191,62 @@ final class StdioLanguageServerSession: LanguageServerSession { ) return } - let response: RustCoreBridge.LspClientResponsePayload? - if openedDocumentURIs.contains(uri) { - response = core.lspClientChangeDocument(state: state, fileURL: standardizedURL, text: text) - } else { - response = core.lspClientOpenDocument( - state: state, - fileURL: standardizedURL, - languageID: languageID, - text: text - ) - openedDocumentURIs.insert(uri) + if let sessionCore = core as? any LspSessionCore, + let sessionID { + let response = openedDocumentURIs.contains(uri) + ? sessionCore.lspSessionChangeDocument( + sessionID: sessionID, + fileURL: standardizedURL, + text: text + ) + : sessionCore.lspSessionOpenDocument( + sessionID: sessionID, + fileURL: standardizedURL, + languageID: languageID, + text: text + ) + if !openedDocumentURIs.contains(uri) { openedDocumentURIs.insert(uri) } + if let response { apply(response) } + } else if let legacyState { + let response: RustCoreBridge.LspClientResponsePayload? + if openedDocumentURIs.contains(uri) { + response = core.lspClientChangeDocument( + state: legacyState, + fileURL: standardizedURL, + text: text + ) + } else { + response = core.lspClientOpenDocument( + state: legacyState, + fileURL: standardizedURL, + languageID: languageID, + text: text + ) + openedDocumentURIs.insert(uri) + } + if let response { apply(response) } } - if let response { apply(response) } } func closeDocument(_ fileURL: URL) { let standardizedURL = fileURL.standardizedFileURL let uri = standardizedURL.absoluteString pendingDocuments[uri] = nil - guard openedDocumentURIs.remove(uri) != nil, - let state, - let response = core.lspClientCloseDocument( - state: state, - fileURL: standardizedURL - ) else { return } - apply(response) + guard openedDocumentURIs.remove(uri) != nil else { return } + if let sessionCore = core as? any LspSessionCore, + let sessionID, + let response = sessionCore.lspSessionCloseDocument( + sessionID: sessionID, + fileURL: standardizedURL + ) { + apply(response) + } else if let legacyState, + let response = core.lspClientCloseDocument( + state: legacyState, + fileURL: standardizedURL + ) { + apply(response) + } } func completions( @@ -315,15 +396,23 @@ final class StdioLanguageServerSession: LanguageServerSession { resetTransientState() return } - guard !isStopping, - isInitialized, - let state, - let response = core.lspClientShutdown(state: state) else { + guard !isStopping, isInitialized else { + forceStop() + return + } + if let sessionCore = core as? any LspSessionCore, + let sessionID, + let response = sessionCore.lspSessionShutdown(sessionID: sessionID) { + isStopping = true + apply(response) + } else if let legacyState, + let response = core.lspClientShutdown(state: legacyState) { + isStopping = true + apply(response) + } else { forceStop() return } - isStopping = true - apply(response) shutdownFallbackTask?.cancel() // The task intentionally retains the session after its manager removes it. shutdownFallbackTask = Task { @MainActor [self] in @@ -334,12 +423,18 @@ final class StdioLanguageServerSession: LanguageServerSession { } private func apply(_ response: RustCoreBridge.LspClientResponsePayload) { - state = response.state + legacyState = response.state updateFeatures(from: response.state) response.messages.forEach(sendRawJSON) handle(response.events) } + private func apply(_ response: RustCoreBridge.LspSessionResponsePayload) { + updateFeatures(capabilityNames: response.serverCapabilities) + response.messages.forEach(sendRawJSON) + handle(response.events) + } + private func handle(_ events: [RustCoreBridge.LspClientEventPayload]) { for event in events { if let requestID = event.requestId, @@ -375,34 +470,57 @@ final class StdioLanguageServerSession: LanguageServerSession { command: LanguageServerCommand? = nil, completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void ) throws { - guard let state, isInitialized else { + guard isInitialized else { throw StdioLanguageServerSessionError.notReady } guard openedDocumentURIs.contains(fileURL.standardizedFileURL.absoluteString) else { throw StdioLanguageServerSessionError.documentNotOpen } - guard let response = core.lspClientRequest( - state: state, - fileURL: fileURL, - method: method, - position: position, - newName: newName, - range: range, - diagnostics: diagnostics, - completionItem: completionItem, - codeAction: codeAction, - command: command - ) else { + let messages: [String] + let events: [RustCoreBridge.LspClientEventPayload] + if let sessionCore = core as? any LspSessionCore, + let sessionID, + let response = sessionCore.lspSessionRequest( + sessionID: sessionID, + fileURL: fileURL, + method: method, + position: position, + newName: newName, + range: range, + diagnostics: diagnostics, + completionItem: completionItem, + codeAction: codeAction, + command: command + ) { + updateFeatures(capabilityNames: response.serverCapabilities) + messages = response.messages + events = response.events + } else if let legacyState, + let response = core.lspClientRequest( + state: legacyState, + fileURL: fileURL, + method: method, + position: position, + newName: newName, + range: range, + diagnostics: diagnostics, + completionItem: completionItem, + codeAction: codeAction, + command: command + ) { + self.legacyState = response.state + messages = response.messages + events = response.events + } else { throw StdioLanguageServerSessionError.requestRejected } - self.state = response.state - for message in response.messages { + for message in messages { if let requestID = Self.requestID(from: message) { responseHandlers[requestID] = completion } sendRawJSON(message) } - handle(response.events) + handle(events) } private func flushPendingDocuments() { @@ -436,8 +554,18 @@ final class StdioLanguageServerSession: LanguageServerSession { ) else { return } readBuffer = Data(parsed.buffer) for message in parsed.messages { - guard let state else { continue } - if let response = core.lspClientApplyServerMessage(state: state, message: message) { + if let sessionCore = core as? any LspSessionCore, + let sessionID, + let response = sessionCore.lspSessionApplyServerMessage( + sessionID: sessionID, + message: message + ) { + apply(response) + } else if let legacyState, + let response = core.lspClientApplyServerMessage( + state: legacyState, + message: message + ) { apply(response) } } @@ -446,7 +574,12 @@ final class StdioLanguageServerSession: LanguageServerSession { private func resetTransientState() { shutdownFallbackTask?.cancel() shutdownFallbackTask = nil - state = nil + if let sessionCore = core as? any LspSessionCore, + let sessionID { + sessionCore.lspSessionDestroy(sessionID: sessionID) + } + sessionID = nil + legacyState = nil readBuffer = Data() openedDocumentURIs = [] pendingDocuments = [:] @@ -473,6 +606,10 @@ final class StdioLanguageServerSession: LanguageServerSession { guard case .string(let name) = value else { return nil } return name } + updateFeatures(capabilityNames: names) + } + + private func updateFeatures(capabilityNames names: [String]) { let updated = names.reduce(into: LanguageServerFeatureSet()) { result, name in switch name { case "definition": result.insert(.definition) diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 525cf48a..7571c825 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1092,6 +1092,7 @@ struct RunConfigurationIntegrationTests { #expect(startRequest.arguments.isEmpty) #expect(startRequest.environment?["SOURCEKIT_TOOLCHAIN"] == "custom") #expect(initializationRecorder.options == .object(["indexing": .bool(true)])) + #expect(initializationRecorder.actions == ["create"]) #expect(manager.activeLanguageServerIDs == ["swift"]) let firstFrameData = try #require(process.sentData.first) let firstFrame = try #require(String(data: firstFrameData, encoding: .utf8)) @@ -1112,6 +1113,8 @@ struct RunConfigurationIntegrationTests { await Self.drainMainActorTasks() #expect(manager.languageServerFeatures["swift"]?.contains(.completion) == true) #expect(manager.languageServerFeatures["swift"]?.contains(.hover) == true) + #expect(initializationRecorder.actions.contains("applyServerMessage")) + #expect(initializationRecorder.actions.contains("openDocument")) let framedOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() #expect(framedOutput.contains("\"method\":\"initialized\"")) @@ -1366,6 +1369,9 @@ struct RunConfigurationIntegrationTests { let shutdownOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined() #expect(shutdownOutput.contains("\"method\":\"exit\"")) #expect(!process.isRunning) + #expect(initializationRecorder.actions.contains("closeDocument")) + #expect(initializationRecorder.actions.contains("shutdown")) + #expect(initializationRecorder.actions.filter { $0 == "destroy" }.count == 1) } @Test @@ -3005,7 +3011,7 @@ private final class RecordingRunExecutableResolver: RunExecutableResolving { } } -private struct TestLspClientCore: LspClientCore { +private struct TestLspClientCore: LspClientCore, LspSessionCore { let diagnosticURL: URL var initializationRecorder: TestLspInitializationRecorder? @@ -3383,6 +3389,99 @@ private struct TestLspClientCore: LspClientCore { return RustCoreBridge.LspParsedMessagesPayload(buffer: Array(data), messages: messages) } + func lspSessionCreate( + rootURL: URL, + initializationOptions: ToolingJSONValue? + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.options = initializationOptions + initializationRecorder?.actions.append("create") + return lspClientInitialize(rootURL: rootURL).map(sessionResponse) + } + + func lspSessionOpenDocument( + sessionID _: String, + fileURL: URL, + languageID: String, + text: String + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.actions.append("openDocument") + return lspClientOpenDocument( + state: .object([:]), + fileURL: fileURL, + languageID: languageID, + text: text + ).map(sessionResponse) + } + + func lspSessionChangeDocument( + sessionID _: String, + fileURL: URL, + text: String + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.actions.append("changeDocument") + return lspClientChangeDocument( + state: .object([:]), + fileURL: fileURL, + text: text + ).map(sessionResponse) + } + + func lspSessionCloseDocument( + sessionID _: String, + fileURL: URL + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.actions.append("closeDocument") + return lspClientCloseDocument(state: .object([:]), fileURL: fileURL) + .map(sessionResponse) + } + + func lspSessionShutdown( + sessionID _: String + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.actions.append("shutdown") + return lspClientShutdown(state: .object([:])).map(sessionResponse) + } + + func lspSessionRequest( + sessionID _: String, + fileURL: URL, + method: String, + position: LanguageServerPosition?, + newName: String?, + range: LanguageServerRange?, + diagnostics: [LanguageServerDiagnostic], + completionItem: LanguageServerCompletionItem?, + codeAction: LanguageServerCodeAction?, + command: LanguageServerCommand? + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.actions.append("request") + return lspClientRequest( + state: .object([:]), + fileURL: fileURL, + method: method, + position: position, + newName: newName, + range: range, + diagnostics: diagnostics, + completionItem: completionItem, + codeAction: codeAction, + command: command + ).map(sessionResponse) + } + + func lspSessionApplyServerMessage( + sessionID _: String, + message: String + ) -> RustCoreBridge.LspSessionResponsePayload? { + initializationRecorder?.actions.append("applyServerMessage") + return lspClientApplyServerMessage(state: .object([:]), message: message) + .map(sessionResponse) + } + + func lspSessionDestroy(sessionID _: String) { + initializationRecorder?.actions.append("destroy") + } + private func response( state: ToolingJSONValue = .object([:]), messages: [String] = [], @@ -3394,10 +3493,36 @@ private struct TestLspClientCore: LspClientCore { events: events ) } + + private func sessionResponse( + _ response: RustCoreBridge.LspClientResponsePayload + ) -> RustCoreBridge.LspSessionResponsePayload { + let capabilities: [String] + if case .object(let state) = response.state, + case .array(let values)? = state["serverCapabilities"] { + capabilities = values.compactMap { + guard case .string(let value) = $0 else { return nil } + return value + } + } else { + capabilities = [] + } + if !capabilities.isEmpty { + initializationRecorder?.serverCapabilities = capabilities + } + return RustCoreBridge.LspSessionResponsePayload( + sessionId: "test-session", + serverCapabilities: initializationRecorder?.serverCapabilities ?? capabilities, + messages: response.messages, + events: response.events + ) + } } private final class TestLspInitializationRecorder: @unchecked Sendable { var options: ToolingJSONValue? + var actions: [String] = [] + var serverCapabilities: [String] = [] } private final class RecordingRawProcessSession: RawProcessSession, @unchecked Sendable { diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 54624c3f..41fd4976 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -112,10 +112,12 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid 当前 transport 是 LSP 标准的 stdio `Content-Length` framing。一个 provider 在一个 workspace root 下复用一个 session;同一 provider 切换到另一个 root 时,manager 会停止旧 session 并创建新 session。 +生产路径由 Rust `LspHost` 持有长生命周期 `sessionID -> LspClientState` registry。Swift 只保存不透明 `sessionID`,open/change/request/server-message/shutdown 请求不再携带整份 state 或已打开文档全文。registry 锁只保护 handle 的查找和增删,每个 session 独立串行化状态变更,因此不同 workspace 不会因一次 LSP reducer 调用而互相阻塞。Rust Core 只返回待发送 JSON-RPC 消息、typed event 和精简 capability 摘要。旧的 reducer API 暂时保留给未迁移 adapter 和纯函数测试,不再用于 `RustCoreBridge` 的 stdio 生产会话。 + 启动顺序: 1. adapter 启动进程并先安装 stdout/stderr handler,避免丢失启动阶段输出; -2. Rust Core 生成 `initialize`,记录 pending request; +2. Rust `LspHost` 创建 session handle、生成 `initialize` 并在内部记录 pending request; 3. 收到响应后,Rust Core 保存服务器 capability 并生成 `initialized`; 4. manager 发布实际 capability,随后通过 `didOpen`/全量 `didChange` 同步文档; 5. 功能请求按 request ID 回到对应 completion handler。 From 7ea32bb9146d8fef666f5e519b80a96f0cc5b086 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 19:25:14 +0800 Subject: [PATCH 26/38] Reorganize Rust core into domain packages --- .../configuration.rs} | 45 +- .../src/{ => execution}/detectors/cargo.rs | 0 .../src/{ => execution}/detectors/compose.rs | 0 .../src/{ => execution}/detectors/go.rs | 2 +- .../src/{ => execution}/detectors/make.rs | 2 +- .../src/{ => execution}/detectors/mod.rs | 4 +- .../src/{ => execution}/detectors/npm.rs | 0 .../src/{ => execution}/detectors/procfile.rs | 2 +- .../src/{ => execution}/detectors/python.rs | 2 +- .../src/{ => execution}/detectors/scan.rs | 2 +- .../src/{ => execution}/detectors/shell.rs | 2 +- rust/lithe-core/src/execution/mod.rs | 7 + rust/lithe-core/src/execution/types.rs | 32 + rust/lithe-core/src/{git.rs => git/mod.rs} | 16 +- rust/lithe-core/src/{ => languages}/java.rs | 12 +- rust/lithe-core/src/languages/mod.rs | 5 + rust/lithe-core/src/lib.rs | 3391 +--------------- rust/lithe-core/src/lsp.rs | 3583 ----------------- rust/lithe-core/src/lsp/interface/client.rs | 1047 +++++ .../{lsp_host.rs => lsp/interface/host.rs} | 42 +- rust/lithe-core/src/lsp/interface/mod.rs | 13 + .../lithe-core/src/lsp/interface/transport.rs | 88 + rust/lithe-core/src/lsp/interface/types.rs | 196 + rust/lithe-core/src/lsp/languages/catalog.rs | 332 ++ rust/lithe-core/src/lsp/languages/mod.rs | 6 + rust/lithe-core/src/lsp/languages/swift.rs | 189 + rust/lithe-core/src/lsp/lightweight/edits.rs | 157 + rust/lithe-core/src/lsp/lightweight/mod.rs | 9 + .../src/lsp/lightweight/snippets.rs | 78 + .../lithe-core/src/lsp/lightweight/symbols.rs | 365 ++ rust/lithe-core/src/lsp/mod.rs | 24 + rust/lithe-core/src/lsp/tests.rs | 1210 ++++++ .../src/{workspace.rs => project/files.rs} | 26 +- rust/lithe-core/src/{ => project}/history.rs | 4 +- rust/lithe-core/src/{ => project}/markdown.rs | 0 rust/lithe-core/src/{ => project}/maven.rs | 4 +- rust/lithe-core/src/project/mod.rs | 11 + .../src/{ => protocol}/cancellation.rs | 4 +- rust/lithe-core/src/{ => protocol}/command.rs | 0 .../src/{model.rs => protocol/contracts.rs} | 2 +- rust/lithe-core/src/{ => protocol}/error.rs | 0 rust/lithe-core/src/{ => protocol}/event.rs | 4 +- rust/lithe-core/src/protocol/mod.rs | 12 + .../src/{runtime.rs => runtime/dispatcher.rs} | 148 +- rust/lithe-core/src/{ => runtime}/ffi.rs | 2 +- rust/lithe-core/src/runtime/mod.rs | 6 + rust/lithe-core/src/tests/detectors.rs | 343 ++ rust/lithe-core/src/tests/git.rs | 1309 ++++++ rust/lithe-core/src/tests/languages.rs | 198 + rust/lithe-core/src/tests/mod.rs | 7 + rust/lithe-core/src/tests/project.rs | 468 +++ rust/lithe-core/src/tests/protocol.rs | 14 + .../lithe-core/src/tests/run_configuration.rs | 1031 +++++ rust/lithe-core/src/tests/support.rs | 19 + 54 files changed, 7326 insertions(+), 7149 deletions(-) rename rust/lithe-core/src/{run_configuration.rs => execution/configuration.rs} (98%) rename rust/lithe-core/src/{ => execution}/detectors/cargo.rs (100%) rename rust/lithe-core/src/{ => execution}/detectors/compose.rs (100%) rename rust/lithe-core/src/{ => execution}/detectors/go.rs (97%) rename rust/lithe-core/src/{ => execution}/detectors/make.rs (98%) rename rust/lithe-core/src/{ => execution}/detectors/mod.rs (98%) rename rust/lithe-core/src/{ => execution}/detectors/npm.rs (100%) rename rust/lithe-core/src/{ => execution}/detectors/procfile.rs (97%) rename rust/lithe-core/src/{ => execution}/detectors/python.rs (98%) rename rust/lithe-core/src/{ => execution}/detectors/scan.rs (99%) rename rust/lithe-core/src/{ => execution}/detectors/shell.rs (97%) create mode 100644 rust/lithe-core/src/execution/mod.rs create mode 100644 rust/lithe-core/src/execution/types.rs rename rust/lithe-core/src/{git.rs => git/mod.rs} (99%) rename rust/lithe-core/src/{ => languages}/java.rs (98%) create mode 100644 rust/lithe-core/src/languages/mod.rs delete mode 100644 rust/lithe-core/src/lsp.rs create mode 100644 rust/lithe-core/src/lsp/interface/client.rs rename rust/lithe-core/src/{lsp_host.rs => lsp/interface/host.rs} (89%) create mode 100644 rust/lithe-core/src/lsp/interface/mod.rs create mode 100644 rust/lithe-core/src/lsp/interface/transport.rs create mode 100644 rust/lithe-core/src/lsp/interface/types.rs create mode 100644 rust/lithe-core/src/lsp/languages/catalog.rs create mode 100644 rust/lithe-core/src/lsp/languages/mod.rs create mode 100644 rust/lithe-core/src/lsp/languages/swift.rs create mode 100644 rust/lithe-core/src/lsp/lightweight/edits.rs create mode 100644 rust/lithe-core/src/lsp/lightweight/mod.rs create mode 100644 rust/lithe-core/src/lsp/lightweight/snippets.rs create mode 100644 rust/lithe-core/src/lsp/lightweight/symbols.rs create mode 100644 rust/lithe-core/src/lsp/mod.rs create mode 100644 rust/lithe-core/src/lsp/tests.rs rename rust/lithe-core/src/{workspace.rs => project/files.rs} (97%) rename rust/lithe-core/src/{ => project}/history.rs (99%) rename rust/lithe-core/src/{ => project}/markdown.rs (100%) rename rust/lithe-core/src/{ => project}/maven.rs (99%) create mode 100644 rust/lithe-core/src/project/mod.rs rename rust/lithe-core/src/{ => protocol}/cancellation.rs (97%) rename rust/lithe-core/src/{ => protocol}/command.rs (100%) rename rust/lithe-core/src/{model.rs => protocol/contracts.rs} (99%) rename rust/lithe-core/src/{ => protocol}/error.rs (100%) rename rust/lithe-core/src/{ => protocol}/event.rs (74%) create mode 100644 rust/lithe-core/src/protocol/mod.rs rename rust/lithe-core/src/{runtime.rs => runtime/dispatcher.rs} (91%) rename rust/lithe-core/src/{ => runtime}/ffi.rs (96%) create mode 100644 rust/lithe-core/src/runtime/mod.rs create mode 100644 rust/lithe-core/src/tests/detectors.rs create mode 100644 rust/lithe-core/src/tests/git.rs create mode 100644 rust/lithe-core/src/tests/languages.rs create mode 100644 rust/lithe-core/src/tests/mod.rs create mode 100644 rust/lithe-core/src/tests/project.rs create mode 100644 rust/lithe-core/src/tests/protocol.rs create mode 100644 rust/lithe-core/src/tests/run_configuration.rs create mode 100644 rust/lithe-core/src/tests/support.rs diff --git a/rust/lithe-core/src/run_configuration.rs b/rust/lithe-core/src/execution/configuration.rs similarity index 98% rename from rust/lithe-core/src/run_configuration.rs rename to rust/lithe-core/src/execution/configuration.rs index 4254f512..2bcc9515 100644 --- a/rust/lithe-core/src/run_configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -1,5 +1,6 @@ -use crate::error::{invalid_relative_path, CoreError, ErrorCode}; -use crate::java::JavaRunConfigurationsRequest; +use super::types::{Confidence, Execution}; +use crate::languages::JavaRunConfigurationsRequest; +use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -185,42 +186,6 @@ pub struct GeneratorMetadata { pub inputs: BTreeMap, } -/// How a configuration behaves once started. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Execution { - /// User-launched program whose lifetime is not assumed to be long-running. - Application, - /// Long-running process: has stop semantics and may expose a port. - Service, - /// Runs to completion and exits. - Task, - /// Orchestrates other configurations by id. - Group, -} - -impl Default for Execution { - fn default() -> Self { - Self::Application - } -} - -/// How the configuration was discovered. Ranks dedupe winners: a native parse -/// beats a manifest declaration, which beats an entry-point guess. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Confidence { - Heuristic, - Declared, - Native, -} - -impl Default for Confidence { - fn default() -> Self { - Self::Declared - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DebugCapability { @@ -383,7 +348,7 @@ pub fn generate(request: GenerateRequest) -> Result { let has_maven_project = root.join("pom.xml").is_file() || root.join("mvnw").is_file(); let has_java_ecosystem = has_java_sources || has_maven_project; let module_paths = inferred_maven_module_paths(&root, &request.paths, request.module_paths); - let scanned = crate::java::run_configurations(JavaRunConfigurationsRequest { + let scanned = crate::languages::run_configurations(JavaRunConfigurationsRequest { root: request.root.clone(), paths: request.paths, module_paths, @@ -507,7 +472,7 @@ fn detected_configurations( root: &Path, claimed: &BTreeSet, ) -> Result, CoreError> { - Ok(crate::detectors::detect_all(root)? + Ok(super::detectors::detect_all(root)? .into_iter() .map(|item| RunConfiguration { id: item.id(), diff --git a/rust/lithe-core/src/detectors/cargo.rs b/rust/lithe-core/src/execution/detectors/cargo.rs similarity index 100% rename from rust/lithe-core/src/detectors/cargo.rs rename to rust/lithe-core/src/execution/detectors/cargo.rs diff --git a/rust/lithe-core/src/detectors/compose.rs b/rust/lithe-core/src/execution/detectors/compose.rs similarity index 100% rename from rust/lithe-core/src/detectors/compose.rs rename to rust/lithe-core/src/execution/detectors/compose.rs diff --git a/rust/lithe-core/src/detectors/go.rs b/rust/lithe-core/src/execution/detectors/go.rs similarity index 97% rename from rust/lithe-core/src/detectors/go.rs rename to rust/lithe-core/src/execution/detectors/go.rs index ee53c760..ef4ea7c2 100644 --- a/rust/lithe-core/src/detectors/go.rs +++ b/rust/lithe-core/src/execution/detectors/go.rs @@ -1,5 +1,5 @@ +use super::super::types::Confidence; use super::{Detected, DirectoryContext}; -use crate::run_configuration::Confidence; /// A Go module is runnable when a `package main` sits next to its `go.mod`, or /// under the conventional `cmd/` layout. There is no declaration listing diff --git a/rust/lithe-core/src/detectors/make.rs b/rust/lithe-core/src/execution/detectors/make.rs similarity index 98% rename from rust/lithe-core/src/detectors/make.rs rename to rust/lithe-core/src/execution/detectors/make.rs index 4854ecf3..19e73ba4 100644 --- a/rust/lithe-core/src/detectors/make.rs +++ b/rust/lithe-core/src/execution/detectors/make.rs @@ -1,5 +1,5 @@ +use super::super::types::Confidence; use super::{Detected, DirectoryContext}; -use crate::run_configuration::Confidence; const FILES: &[&str] = &["Makefile", "makefile", "GNUmakefile"]; diff --git a/rust/lithe-core/src/detectors/mod.rs b/rust/lithe-core/src/execution/detectors/mod.rs similarity index 98% rename from rust/lithe-core/src/detectors/mod.rs rename to rust/lithe-core/src/execution/detectors/mod.rs index 3253696b..8fcffc33 100644 --- a/rust/lithe-core/src/detectors/mod.rs +++ b/rust/lithe-core/src/execution/detectors/mod.rs @@ -15,8 +15,8 @@ mod python; mod scan; mod shell; -use crate::error::CoreError; -use crate::run_configuration::{Confidence, Execution}; +use super::types::{Confidence, Execution}; +use crate::protocol::CoreError; use std::collections::BTreeMap; use std::path::Path; diff --git a/rust/lithe-core/src/detectors/npm.rs b/rust/lithe-core/src/execution/detectors/npm.rs similarity index 100% rename from rust/lithe-core/src/detectors/npm.rs rename to rust/lithe-core/src/execution/detectors/npm.rs diff --git a/rust/lithe-core/src/detectors/procfile.rs b/rust/lithe-core/src/execution/detectors/procfile.rs similarity index 97% rename from rust/lithe-core/src/detectors/procfile.rs rename to rust/lithe-core/src/execution/detectors/procfile.rs index 195be823..9b3dccff 100644 --- a/rust/lithe-core/src/detectors/procfile.rs +++ b/rust/lithe-core/src/execution/detectors/procfile.rs @@ -1,5 +1,5 @@ +use super::super::types::Confidence; use super::{Detected, DirectoryContext}; -use crate::run_configuration::Confidence; const FILES: &[&str] = &["Procfile", "Procfile.dev", "Procfile.local"]; diff --git a/rust/lithe-core/src/detectors/python.rs b/rust/lithe-core/src/execution/detectors/python.rs similarity index 98% rename from rust/lithe-core/src/detectors/python.rs rename to rust/lithe-core/src/execution/detectors/python.rs index 274b62da..1e83d1ba 100644 --- a/rust/lithe-core/src/detectors/python.rs +++ b/rust/lithe-core/src/execution/detectors/python.rs @@ -1,5 +1,5 @@ +use super::super::types::Confidence; use super::{Detected, DirectoryContext}; -use crate::run_configuration::Confidence; pub fn detect(ctx: &DirectoryContext) -> Vec { let mut detected = pyproject(ctx); diff --git a/rust/lithe-core/src/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs similarity index 99% rename from rust/lithe-core/src/detectors/scan.rs rename to rust/lithe-core/src/execution/detectors/scan.rs index 2ea626bc..6d1fdf5d 100644 --- a/rust/lithe-core/src/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, ErrorCode}; +use crate::protocol::{CoreError, ErrorCode}; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; diff --git a/rust/lithe-core/src/detectors/shell.rs b/rust/lithe-core/src/execution/detectors/shell.rs similarity index 97% rename from rust/lithe-core/src/detectors/shell.rs rename to rust/lithe-core/src/execution/detectors/shell.rs index 823f6c63..54b3a666 100644 --- a/rust/lithe-core/src/detectors/shell.rs +++ b/rust/lithe-core/src/execution/detectors/shell.rs @@ -1,5 +1,5 @@ +use super::super::types::Confidence; use super::{Detected, DirectoryContext}; -use crate::run_configuration::Confidence; const JUSTFILES: &[&str] = &["justfile", "Justfile", ".justfile"]; diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs new file mode 100644 index 00000000..b67d3a90 --- /dev/null +++ b/rust/lithe-core/src/execution/mod.rs @@ -0,0 +1,7 @@ +//! Run configuration generation, resolution, and project detectors. + +mod configuration; +mod detectors; +mod types; + +pub(crate) use configuration::*; diff --git a/rust/lithe-core/src/execution/types.rs b/rust/lithe-core/src/execution/types.rs new file mode 100644 index 00000000..9a943b3f --- /dev/null +++ b/rust/lithe-core/src/execution/types.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// How a configuration behaves once started. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Execution { + Application, + Service, + Task, + Group, +} + +impl Default for Execution { + fn default() -> Self { + Self::Application + } +} + +/// How the configuration was discovered. Higher values win deduplication. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Confidence { + Heuristic, + Declared, + Native, +} + +impl Default for Confidence { + fn default() -> Self { + Self::Declared + } +} diff --git a/rust/lithe-core/src/git.rs b/rust/lithe-core/src/git/mod.rs similarity index 99% rename from rust/lithe-core/src/git.rs rename to rust/lithe-core/src/git/mod.rs index bd7e94f8..afbfe030 100644 --- a/rust/lithe-core/src/git.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1,5 +1,5 @@ -use crate::error::{CoreError, ErrorCode}; -use crate::model::{ +use crate::protocol::{CoreError, ErrorCode}; +use crate::protocol::{ GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse, GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse, GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, @@ -443,7 +443,7 @@ fn execute_git( arguments: &[String], input: Option, ) -> Result { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; let mut process = Command::new("git"); process.args(arguments).current_dir(root); process.stdin(if input.is_some() { @@ -494,7 +494,7 @@ fn execute_git( })? { break status; } - if let Err(error) = crate::cancellation::check() { + if let Err(error) = crate::protocol::cancellation::check() { let _ = child.kill(); let _ = child.wait(); let _ = stdout_reader.join(); @@ -2528,9 +2528,11 @@ mod tests { #[test] fn structured_diff_matches_shared_fixture() { - let fixture: Value = - serde_json::from_str(include_str!("../../../shared/fixtures/git/diff.json")) - .expect("diff fixture should be valid JSON"); + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/diff.json" + ))) + .expect("diff fixture should be valid JSON"); let patch = fixture["patch"] .as_str() .expect("fixture patch should be text"); diff --git a/rust/lithe-core/src/java.rs b/rust/lithe-core/src/languages/java.rs similarity index 98% rename from rust/lithe-core/src/java.rs rename to rust/lithe-core/src/languages/java.rs index b554f327..aa0eb769 100644 --- a/rust/lithe-core/src/java.rs +++ b/rust/lithe-core/src/languages/java.rs @@ -1,5 +1,5 @@ -use crate::error::{CoreError, ErrorCode}; -use crate::model::{ +use crate::protocol::{CoreError, ErrorCode}; +use crate::protocol::{ JavaClassNameResponse, JavaCodeVisionHintResponse, JavaCodeVisionResponse, JavaFoldRegionResponse, JavaImplementationMarkerResponse, JavaInlayHintResponse, JavaMainClassResponse, JavaRunConfigurationResponse, JavaRunConfigurationsResponse, @@ -183,7 +183,7 @@ pub fn class_name(request: JavaClassNameRequest) -> Result Result, CoreError> { +) -> Result, CoreError> { let lines = request.source.split('\n').collect::>(); if let Some(member) = request.member_name { let method = @@ -199,7 +199,7 @@ pub fn source_definition( && !prefix.contains('#') && !prefix.ends_with('.') { - return Ok(Some(crate::model::JavaSourceDefinitionResponse { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { line: line_number, utf16_column: utf16_column(line, found.start()), })); @@ -213,7 +213,7 @@ pub fn source_definition( })?; for (line_number, line) in lines.iter().enumerate() { if let Some(found) = field.find(line) { - return Ok(Some(crate::model::JavaSourceDefinitionResponse { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { line: line_number, utf16_column: utf16_column(line, found.start()), })); @@ -235,7 +235,7 @@ pub fn source_definition( .find(&request.declaration_name) .map(|value| found.start() + value) .unwrap_or(found.start()); - return Ok(Some(crate::model::JavaSourceDefinitionResponse { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { line: line_number, utf16_column: utf16_column(line, offset), })); diff --git a/rust/lithe-core/src/languages/mod.rs b/rust/lithe-core/src/languages/mod.rs new file mode 100644 index 00000000..e315d2c4 --- /dev/null +++ b/rust/lithe-core/src/languages/mod.rs @@ -0,0 +1,5 @@ +//! Language-specific project inspection that is independent from LSP transport. + +mod java; + +pub(crate) use java::*; diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 75a6c2dc..0f956c23 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -1,25 +1,14 @@ -mod cancellation; -mod command; -mod detectors; -mod error; -mod event; -mod ffi; +mod execution; mod git; -mod history; -mod java; +mod languages; mod lsp; -mod lsp_host; -mod markdown; -mod maven; -mod model; -mod run_configuration; +mod project; +mod protocol; mod runtime; -mod workspace; -pub use command::{CoreCommand, CoreRequest}; -pub use error::{CoreError, ErrorCode}; -pub use event::CoreEvent; -pub use model::{CoreResponse, ResponseData}; +pub use protocol::{ + CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, +}; /// Executes one versioned application command and returns a JSON response. pub fn execute_json(request: &str) -> String { @@ -27,3368 +16,4 @@ pub fn execute_json(request: &str) -> String { } #[cfg(test)] -mod tests { - use super::execute_json; - use serde_json::Value; - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn fixture() -> Value { - let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/search/basic.json"); - serde_json::from_str(&fs::read_to_string(fixture_path).expect("fixture should be readable")) - .expect("fixture should be valid JSON") - } - - fn temporary_root(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be valid") - .as_nanos(); - std::env::temp_dir().join(format!("lithe-core-{label}-{}-{nonce}", std::process::id())) - } - - #[test] - fn ping_exposes_protocol_version() { - let response: Value = serde_json::from_str(&execute_json( - r#"{"id":"test-1","command":"core.ping","payload":{}}"#, - )) - .expect("ping response should be JSON"); - - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["protocolVersion"], 1); - assert_eq!(response["data"]["coreVersion"], "0.1.0"); - } - - #[test] - fn run_configuration_commands_generate_merge_and_plan() { - let root = temporary_root("run-config"); - fs::create_dir_all(root.join("src/main/java/com/example")) - .expect("source directory should be creatable"); - fs::write(root.join("src/main/java/com/example/App.java"), "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }").expect("source should be writable"); - fs::write(root.join("pom.xml"), "21").expect("pom should be writable"); - - let request = serde_json::json!({"id":"generate","command":"runConfig.generate","payload":{"root":root,"paths":["src/main/java/com/example/App.java"],"modulePaths":[]}}); - let generated: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("generate response should be JSON"); - assert_eq!(generated["ok"], true); - assert_eq!(generated["data"]["generated"]["version"], 2); - assert!(generated["data"]["generated"]["configurations"] - .as_array() - .unwrap() - .iter() - .any(|v| v["id"] == "current-file")); - - let generated_doc = serde_json::to_string(&generated["data"]["generated"]).unwrap(); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write(root.join(".lithe/run/generated.json"), generated_doc).unwrap(); - fs::write(root.join(".lithe/run/configurations.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"My File","type":"java.current-file","workingDirectory":"backend","jvmArguments":["-Xmx2g"],"toolchains":{"maven":"custom-maven"}}]}"#).unwrap(); - fs::write(root.join(".lithe/run/local.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"Local File","type":"java.current-file","workingDirectory":".","programArguments":["--dev"],"toolchains":{"java":"custom-jdk"}}]}"#).unwrap(); - - let resolve: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"resolve","command":"runConfig.resolve","payload":{"root":root}}).to_string())).unwrap(); - assert_eq!(resolve["ok"], true); - let current = resolve["data"]["configurations"] - .as_array() - .unwrap() - .iter() - .find(|v| v["id"] == "current-file") - .unwrap(); - assert_eq!(current["name"], "Local File"); - assert_eq!(current["toolchains"]["java"], "custom-jdk"); - assert_eq!(current["toolchains"]["maven"], "custom-maven"); - let plan: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"plan","command":"runConfig.createLaunchPlan","payload":{"root":root,"configurationId":"current-file","currentFile":"src/main/java/com/example/App.java"}}).to_string())).unwrap(); - assert_eq!(plan["ok"], true); - assert_eq!(plan["data"]["executable"]["toolchain"], "custom-jdk"); - let debug_plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id":"debug-plan", - "command":"runConfig.createLaunchPlan", - "payload":{ - "root":root, - "configurationId":"spring:com.example.App", - "debugPort":5005 - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(debug_plan["ok"], true); - assert!(debug_plan["data"]["arguments"] - .as_array() - .unwrap() - .iter() - .filter_map(Value::as_str) - .any(|argument| argument.contains("address=127.0.0.1:5005"))); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_generation_infers_maven_modules_from_nearest_pom() { - let root = temporary_root("run-config-inferred-modules"); - let backend = "backend-api/src/main/java/com/example/BackendApplication.java"; - let worker = "batch-worker/src/main/java/com/example/WorkerMain.java"; - fs::create_dir_all(root.join("backend-api/src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); - fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("backend-api/pom.xml"), "").unwrap(); - fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); - fs::write( - root.join(backend), - "package com.example; @SpringBootApplication class BackendApplication { public static void main(String[] args) {} }", - ) - .unwrap(); - fs::write( - root.join(worker), - "package com.example; class WorkerMain { public static void main(String[] args) {} }", - ) - .unwrap(); - - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-inferred-modules", - "command": "runConfig.generate", - "payload": { - "root": root, - "paths": [backend, worker], - "modulePaths": [] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true); - let configurations = response["data"]["generated"]["configurations"] - .as_array() - .unwrap(); - assert!(configurations.iter().any(|value| { - value["id"] == "spring:com.example.BackendApplication" - && value["extensions"]["maven"]["module"] == "backend-api" - })); - assert!(configurations.iter().any(|value| { - value["id"] == "java-main:com.example.WorkerMain" - && value["extensions"]["maven"]["module"] == "batch-worker" - && value["execution"] == "application" - })); - assert!(!configurations - .iter() - .any(|value| value["provider"] == "maven.module")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn ordinary_java_main_uses_an_application_launch_plan() { - let root = temporary_root("run-config-java-main"); - let source = "batch-worker/src/main/java/com/example/WorkerMain.java"; - fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); - fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); - fs::write( - root.join(source), - "package com.example; class WorkerMain { public static void main(String[] args) {} }", - ) - .unwrap(); - - let generated_response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-java-main", - "command": "runConfig.generate", - "payload": {"root": root, "paths": [source], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - let generated = &generated_response["data"]["generated"]; - let java_main = generated["configurations"] - .as_array() - .unwrap() - .iter() - .find(|value| value["provider"] == "java.main") - .unwrap(); - assert_eq!(java_main["execution"], "application"); - assert_eq!(java_main["extensions"]["maven"]["module"], "batch-worker"); - - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - serde_json::to_string(generated).unwrap(), - ) - .unwrap(); - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "plan-java-main", - "command": "runConfig.createLaunchPlan", - "payload": { - "root": root, - "configurationId": "java-main:com.example.WorkerMain" - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); - assert!(plan["data"]["arguments"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "-Dexec.mainClass=com.example.WorkerMain")); - assert_eq!( - plan["data"]["arguments"] - .as_array() - .unwrap() - .last() - .unwrap(), - "org.codehaus.mojo:exec-maven-plugin:3.5.0:java" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { - let root = temporary_root("run-config-errors"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write(root.join(".lithe/run/generated.json"), "{").unwrap(); - - let inspect = |id: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": id, - "command": "runConfig.inspect", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap() - }; - let malformed = inspect("malformed"); - assert_eq!(malformed["ok"], false); - assert_eq!(malformed["error"]["code"], "parse_failed"); - - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":3,"configurations":[]}"#, - ) - .unwrap(); - let unsupported = inspect("unsupported"); - assert_eq!(unsupported["ok"], false); - assert_eq!(unsupported["error"]["code"], "not_supported"); - assert!(unsupported["error"]["details"] - .as_str() - .unwrap() - .contains("found 3")); - - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[]}"#, - ) - .unwrap(); - fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); - fs::write(root.join(".lithe/toolchains/local.json"), "{").unwrap(); - let malformed_toolchains = inspect("malformed-toolchains"); - assert_eq!(malformed_toolchains["ok"], false); - assert_eq!(malformed_toolchains["error"]["code"], "parse_failed"); - assert!(malformed_toolchains["error"]["message"] - .as_str() - .unwrap() - .contains(".lithe/toolchains/local.json")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_mutations_are_shared_and_validated() { - let root = temporary_root("run-config-mutations"); - fs::create_dir_all(root.join("src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join("src/main/java/com/example/App.java"), - "package com.example; class App { public static void main(String[] args) {} }", - ) - .unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file","toolchains":{"java":"project-jdk"}}]}"#, - ) - .unwrap(); - - let updated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "update-options", - "command": "runConfig.updateOptions", - "payload": { - "root": root, - "scope": "project", - "configurationId": "current-file", - "workingDirectory": ".", - "jvmArguments": "\"-Dlabel=hello world\" -Xmx2g", - "programArguments": "--dev", - "mavenProfiles": ["dev"] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(updated["ok"], true); - let updated_document: Value = - serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); - assert_eq!( - updated_document["configurations"][0]["extensions"]["maven"]["jvmArguments"], - serde_json::json!(["-Dlabel=hello world", "-Xmx2g"]) - ); - fs::write( - root.join(".lithe/run/configurations.json"), - updated["data"]["document"].as_str().unwrap(), - ) - .unwrap(); - - let create = |name: &str, module: &str, main_class: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "create-user", - "command": "runConfig.createUserConfiguration", - "payload": { - "root": root, - "scope": "project", - "name": name, - "type": "springBoot", - "module": module, - "mainClass": main_class - } - }) - .to_string(), - )) - .unwrap() - }; - let first = create("Backend Dev", ".", "com.example.App"); - assert_eq!(first["data"]["id"], "user:backend-dev"); - fs::write( - root.join(".lithe/run/configurations.json"), - first["data"]["document"].as_str().unwrap(), - ) - .unwrap(); - let second = create("Backend Dev", ".", "com.example.App"); - assert_eq!(second["data"]["id"], "user:backend-dev-2"); - assert_eq!( - create("Outside", "../outside", "com.example.App")["ok"], - false - ); - assert_eq!(create("Missing", ".", "com.example.Missing")["ok"], false); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_generation_detects_declared_toolchain_versions() { - let root = temporary_root("run-config-toolchains"); - fs::create_dir_all(root.join(".mvn/wrapper")).unwrap(); - fs::write(root.join(".sdkmanrc"), "java=21.0.5-tem\n").unwrap(); - fs::write(root.join("mvnw"), "#!/bin/sh\n").unwrap(); - fs::write( - root.join(".mvn/wrapper/maven-wrapper.properties"), - "distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip\n", - ) - .unwrap(); - - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-toolchains", - "command": "runConfig.generate", - "payload": {"root": root, "paths": [], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["ok"], true); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - ["minimumVersion"], - "21" - ); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - ["preferredVendor"], - "temurin" - ); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"], - "3.9.9" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_generation_detects_maven_compiler_target() { - let root = temporary_root("run-config-compiler-target"); - fs::create_dir_all(&root).unwrap(); - fs::write( - root.join("pom.xml"), - "17", - ) - .unwrap(); - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-target", - "command": "runConfig.generate", - "payload": {"root": root, "paths": [], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - ["minimumVersion"], - "17" - ); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_inspection_summarizes_changed_inputs() { - let root = temporary_root("run-config-input-summary"); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/App.java"), "class App {}").unwrap(); - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-summary", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - let generated_again: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-summary-again", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["data"], generated_again["data"]); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - serde_json::to_string(&generated["data"]["generated"]).unwrap(), - ) - .unwrap(); - fs::write(root.join("src/App.java"), "class App { int changed; }").unwrap(); - - let inspected: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "inspect-summary", - "command": "runConfig.inspect", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(inspected["ok"], true); - assert_eq!( - inspected["data"]["diagnostics"][0]["message"], - "Project inputs changed: 0 added, 0 removed, 1 modified" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_resolve_matches_toolchains_and_rejects_unsafe_paths() { - let root = temporary_root("run-config-toolchain-resolution"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/toolchains/requirements.json"), - r#"{"version":1,"toolchains":{"project-jdk":{"type":"java","minimumVersion":"21","preferredVendor":"temurin"}}}"#, - ) - .unwrap(); - - let resolve = |version: &str, vendor: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-toolchains", - "command": "runConfig.resolve", - "payload": { - "root": root, - "toolchainCandidates": [{ - "id": "project-jdk", - "type": "java", - "version": version, - "vendor": vendor - }] - } - }) - .to_string(), - )) - .unwrap() - }; - let mismatch = resolve("17.0.12", "Zulu"); - assert!(mismatch["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "toolchainVersionMismatch")); - assert!(mismatch["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "toolchainVendorMismatch")); - - let matching = resolve("21.0.5", "Eclipse Temurin"); - assert!(matching["data"]["diagnostics"] - .as_array() - .unwrap() - .is_empty()); - - fs::write( - root.join(".lithe/run/local.json"), - r#"{"version":1,"configurations":[{"id":"current-file","workingDirectory":"../outside"}]}"#, - ) - .unwrap(); - let unsafe_path = resolve("21.0.5", "Eclipse Temurin"); - assert_eq!(unsafe_path["ok"], false); - assert_eq!(unsafe_path["error"]["code"], "invalid_request"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_main_class_validation_uses_the_declared_package() { - let root = temporary_root("run-config-main-class-package"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join("src/main/java/other")).unwrap(); - fs::write( - root.join("src/main/java/other/App.java"), - "package other; class App {}", - ) - .unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"spring:com.example.App","name":"App","type":"spring-boot.maven","mainClass":"com.example.App"}]}"#, - ) - .unwrap(); - - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-main-class", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "missingMainClass")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { - let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/run-configuration"); - let mut fixture_count = 0; - for entry in fs::read_dir(directory).unwrap() { - let path = entry.unwrap().path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - fixture_count += 1; - let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(value["version"], 1, "{}", path.display()); - assert!(value["expected"].is_object(), "{}", path.display()); - if let Some(generated) = value.get("generated") { - assert!(generated["version"].is_number(), "{}", path.display()); - assert!(generated["configurations"].is_array(), "{}", path.display()); - } - } - assert!(fixture_count >= 6); - } - - #[test] - fn run_configuration_resolve_diagnoses_orphans_and_deleted_modules() { - let root = temporary_root("run-config-diagnostics"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"},{"id":"module:deleted","name":"Deleted","type":"maven.module","module":"deleted"}]}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/run/local.json"), - r#"{"version":1,"configurations":[{"id":"module:old","jvmArguments":["-Xmx1g"]}]}"#, - ) - .unwrap(); - - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-diagnostics", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "orphanedOverride")); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "missingModule")); - - fs::write( - root.join(".lithe/run/local.json"), - r#"{"version":1,"configurations":[{"id":"module:deleted","jvmArguments":["-Xmx1g"]}]}"#, - ) - .unwrap(); - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-missing-module", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true); - assert_eq!( - resolved["data"]["configurations"].as_array().unwrap().len(), - 1 - ); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "missingModule")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn markdown_render_command_returns_sanitized_html() { - let request = serde_json::json!({ - "id": "markdown-1", - "command": "markdown.render", - "payload": { - "source": "# Preview\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n```plantuml\nAlice -> Bob\n```\n\n" - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Markdown request should encode"), - )) - .expect("Markdown response should be JSON"); - - assert_eq!(response["id"], "markdown-1"); - assert_eq!(response["ok"], true); - let html = response["data"]["html"] - .as_str() - .expect("Markdown response should contain HTML"); - assert!(html.contains(" Vec { - let request = serde_json::json!({ - "id": "mask", - "command": "workspace.search", - "payload": { - "root": root, - "query": "total", - "fileMask": mask - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("search request should encode"), - )) - .expect("search response should be JSON"); - assert_eq!(response["ok"], true); - response["data"]["matches"] - .as_array() - .expect("matches should be an array") - .iter() - .map(|value| value["path"].as_str().unwrap_or_default().to_string()) - .collect() - }; - - let unfiltered = search(""); - assert!(unfiltered.iter().any(|path| path.ends_with("Service.java"))); - assert!(unfiltered.iter().any(|path| path.ends_with("notes.txt"))); - - let java_only = search("*.java"); - assert!(java_only.iter().any(|path| path.ends_with("Service.java"))); - assert!(!java_only.iter().any(|path| path.ends_with("notes.txt"))); - - // 多个掩码取并集,且容忍逗号后的空格。 - let both = search("*.java, *.txt"); - assert!(both.iter().any(|path| path.ends_with("Service.java"))); - assert!(both.iter().any(|path| path.ends_with("notes.txt"))); - - fs::remove_dir_all(root).expect("temporary fixture should be removable"); - } - - #[test] - fn preserve_case_matches_original_occurrence_shape() { - let root = temporary_root("preserve-case"); - fs::create_dir_all(&root).expect("fixture directory should be creatable"); - let relative = "Sample.java"; - fs::write(root.join(relative), "fooBar FooBar FOOBAR fooBar();\n") - .expect("fixture should be writable"); - - let replace = |preserve_case: bool| -> String { - let request = serde_json::json!({ - "id": "preserve", - "command": "workspace.replacePreview", - "payload": { - "root": root, - "query": "fooBar", - "replacement": "bazQux", - "caseSensitive": false, - "preserveCase": preserve_case, - "paths": [relative] - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("replace request should encode"), - )) - .expect("replace response should be JSON"); - assert_eq!(response["ok"], true); - response["data"]["files"][0]["matches"][0]["after"] - .as_str() - .expect("after text should be a string") - .to_string() - }; - - assert_eq!(replace(false), "bazQux bazQux bazQux bazQux();"); - assert_eq!(replace(true), "bazQux BazQux BAZQUX bazQux();"); - - fs::remove_dir_all(root).expect("temporary fixture should be removable"); - } - - #[test] - fn local_history_records_deduplicates_lists_and_relocates() { - let root = temporary_root("history"); - fs::create_dir_all(&root).expect("history workspace should be creatable"); - let storage = root.join("history-storage"); - - let request = |command: &str, payload: Value| -> Value { - let request = serde_json::json!({ - "id": command, - "command": command, - "payload": payload - }); - serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("history request should encode"), - )) - .expect("history response should be JSON") - }; - - let record_payload = |content: &str| { - serde_json::json!({ - "workspaceRoot": root, - "storageRoot": storage, - "path": "src/Main.java", - "reason": "saved", - "content": content, - "hiddenDirectoryNames": [], - "hiddenFilePatterns": [] - }) - }; - let first = request("history.record", record_payload("one\n")); - assert_eq!(first["ok"], true); - assert!(first["data"]["id"].as_str().is_some()); - let duplicate = request("history.record", record_payload("one\n")); - assert_eq!(duplicate["ok"], true); - assert!(duplicate["data"].is_null()); - let mut invalid_reason = record_payload("invalid\n"); - invalid_reason["reason"] = serde_json::json!("not-a-history-reason"); - let invalid = request("history.record", invalid_reason); - assert_eq!(invalid["ok"], false); - assert_eq!(invalid["error"]["code"], "invalid_request"); - - let second = request("history.record", record_payload("two\n")); - assert_eq!(second["ok"], true); - let listed = request( - "history.entries", - serde_json::json!({ - "workspaceRoot": root, - "storageRoot": storage, - "path": "src/Main.java" - }), - ); - assert_eq!(listed["ok"], true); - assert_eq!(listed["data"]["entries"].as_array().unwrap().len(), 2); - let content_path = listed["data"]["entries"][0]["contentPath"] - .as_str() - .unwrap(); - let content = request( - "history.content", - serde_json::json!({ - "storageRoot": storage, - "contentPath": content_path - }), - ); - assert_eq!(content["data"]["text"], "two\n"); - - let relocated = request( - "history.relocate", - serde_json::json!({ - "storageRoot": storage, - "sourcePath": "src/Main.java", - "destinationPath": "src/Renamed.java" - }), - ); - assert_eq!(relocated["ok"], true); - let relocated_entries = request( - "history.entries", - serde_json::json!({ - "workspaceRoot": root, - "storageRoot": storage, - "path": "src/Renamed.java" - }), - ); - assert_eq!( - relocated_entries["data"]["entries"] - .as_array() - .unwrap() - .len(), - 2 - ); - - let traversal = request( - "history.content", - serde_json::json!({ - "storageRoot": storage, - "contentPath": "../outside.snapshot" - }), - ); - assert_eq!(traversal["ok"], false); - assert_eq!(traversal["error"]["code"], "invalid_request"); - fs::remove_dir_all(root).expect("history workspace should be removable"); - } - - #[test] - fn file_commands_round_trip_and_reject_traversal() { - let root = temporary_root("file"); - let outside = temporary_root("outside"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - fs::create_dir_all(&outside).expect("outside directory should be creatable"); - - let write = serde_json::json!({ - "id": "write", - "command": "file.write", - "payload": {"root": root, "path": "nested/example.txt", "text": "hello"} - }); - let write_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&write).expect("write request should encode"), - )) - .expect("write response should be JSON"); - assert_eq!(write_response["ok"], true); - - let read = serde_json::json!({ - "id": "read", - "command": "file.read", - "payload": {"root": root, "path": "nested/example.txt"} - }); - let read_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&read).expect("read request should encode"), - )) - .expect("read response should be JSON"); - assert_eq!(read_response["data"]["text"], "hello"); - - let traversal = serde_json::json!({ - "id": "traversal", - "command": "file.read", - "payload": {"root": root, "path": "../outside.txt"} - }); - let traversal_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&traversal).expect("traversal request should encode"), - )) - .expect("traversal response should be JSON"); - assert_eq!(traversal_response["ok"], false); - assert_eq!(traversal_response["error"]["code"], "invalid_request"); - - for path in [ - "..\\outside.txt", - "nested\\..\\outside.txt", - "C:\\outside.txt", - ] { - let request = serde_json::json!({ - "id": "windows-path", - "command": "file.read", - "payload": {"root": root, "path": path} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Windows path request should encode"), - )) - .expect("Windows path response should be JSON"); - assert_eq!(response["ok"], false, "path {path} should be rejected"); - assert_eq!(response["error"]["code"], "invalid_request"); - } - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&outside, root.join("link")) - .expect("test symlink should be creatable"); - let symlink_write = serde_json::json!({ - "id": "symlink-write", - "command": "file.write", - "payload": {"root": root, "path": "link/escape.txt", "text": "outside"} - }); - let symlink_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&symlink_write).expect("symlink request should encode"), - )) - .expect("symlink response should be JSON"); - assert_eq!(symlink_response["ok"], false); - assert_eq!(symlink_response["error"]["code"], "permission_denied"); - assert!(!outside.join("escape.txt").exists()); - } - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - fs::remove_dir_all(outside).expect("outside fixture should be removable"); - } - - #[test] - fn git_status_returns_contract_shape() { - let root = temporary_root("git"); - fs::create_dir_all(&root).expect("temporary repository should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - fs::write(root.join("new.txt"), "new").expect("test file should be writable"); - - let request = serde_json::json!({ - "id": "git", - "command": "git.status", - "payload": {"root": root} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Git request should encode"), - )) - .expect("Git response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["repositoryRoot"], "."); - assert_eq!(response["data"]["changes"][0]["path"], "new.txt"); - assert_eq!(response["data"]["changes"][0]["untracked"], true); - - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn git_command_returns_combined_output_and_exit_code() { - let root = temporary_root("git-command"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - - let request = serde_json::json!({ - "id": "git-command", - "command": "git.command", - "payload": { - "root": root, - "arguments": ["--version"] - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Git command request should encode"), - )) - .expect("Git command response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["exitCode"], 0); - assert!(response["data"]["output"] - .as_str() - .expect("Git version output should be text") - .contains("git version")); - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - } - - #[test] - fn git_write_validates_and_executes_shared_mutations() { - let root = temporary_root("git-write"); - fs::create_dir_all(&root).expect("temporary repository should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); - - let request = |operation: &str, payload: Value| -> Value { - let request = serde_json::json!({ - "id": operation, - "command": "git.write", - "payload": { - "root": root, - "operation": operation, - "paths": [], - "reference": null, - "referenceKind": null, - "revision": null, - "name": null, - "message": null, - "remote": null, - "destination": null, - "mode": null, - "includeUntracked": false, - "checkout": false, - "amend": false - } - }); - let mut request = request; - if let Value::Object(overrides) = payload { - for (key, value) in overrides { - request["payload"][key.as_str()] = value; - } - } - serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("write request should encode"), - )) - .expect("write response should be JSON") - }; - - let stage = request("stage", serde_json::json!({"paths": ["example.txt"]})); - assert_eq!(stage["ok"], true); - let commit = request( - "commit", - serde_json::json!({"message": "initial", "amend": false}), - ); - assert_eq!(commit["ok"], true); - - fs::write(root.join("example.txt"), "staged change\n").expect("file should be writable"); - assert_eq!( - request("stage", serde_json::json!({"paths": ["example.txt"]}))["ok"], - true - ); - assert_eq!( - request("unstage", serde_json::json!({"paths": ["example.txt"]}))["ok"], - true - ); - assert_eq!( - String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), - " M example.txt\n" - ); - assert_eq!( - request("discard", serde_json::json!({"paths": ["example.txt"]}))["ok"], - true - ); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "initial\n" - ); - - // Conflict-dialog rollback must discard both sides of a file, including - // a staged edit followed by a working-tree edit. - fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); - let discard_all = request("discardAll", serde_json::json!({"paths": ["example.txt"]})); - assert_eq!(discard_all["ok"], true, "{discard_all:?}"); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "initial\n" - ); - assert_eq!( - String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), - "" - ); - - fs::write(root.join("untracked.txt"), "discard me\n") - .expect("untracked file should be writable"); - assert_eq!( - request("discard", serde_json::json!({"paths": ["untracked.txt"]}))["ok"], - true - ); - assert!(!root.join("untracked.txt").exists()); - - let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) - .trim() - .to_string(); - let create = request( - "createBranch", - serde_json::json!({ - "reference": format!("refs/heads/{current}"), - "name": "feature/core", - "checkout": true - }), - ); - assert_eq!(create["ok"], true); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - "feature/core" - ); - - let checkout = request( - "checkout", - serde_json::json!({ - "reference": format!("refs/heads/{current}"), - "referenceKind": "local" - }), - ); - assert_eq!(checkout["ok"], true); - assert_eq!(checkout["data"]["exitCode"], 0, "{checkout:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - current - ); - - // Nested branch names go through the same short-name path. - assert!(run(&["branch", "feature/nested"]).status.success()); - let nested = request( - "checkout", - serde_json::json!({ - "reference": "refs/heads/feature/nested", - "referenceKind": "local" - }), - ); - assert_eq!(nested["ok"], true); - assert_eq!(nested["data"]["exitCode"], 0, "{nested:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - "feature/nested" - ); - assert!(run(&["switch", ¤t]).status.success()); - - fs::write(root.join("example.txt"), "working tree\n").expect("file should be writable"); - let stash = request( - "stashPush", - serde_json::json!({"message": "core write", "includeUntracked": false}), - ); - assert_eq!(stash["ok"], true); - let pop = request("stashPop", serde_json::json!({"reference": "stash@{0}"})); - assert_eq!(pop["ok"], true); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "working tree\n" - ); - - // Checkout conflict handling. `feature/core` and the current branch hold different - // content for conflict.txt, so a dirty working copy of it blocks a plain switch. - fs::write(root.join("conflict.txt"), "on main\n").expect("file should be writable"); - assert!(run(&["add", "conflict.txt"]).status.success()); - assert!(run(&["commit", "-qm", "main conflict"]).status.success()); - assert!(run(&["switch", "feature/core"]).status.success()); - fs::write(root.join("conflict.txt"), "on feature\n").expect("file should be writable"); - assert!(run(&["add", "conflict.txt"]).status.success()); - assert!(run(&["commit", "-qm", "feature conflict"]).status.success()); - assert!(run(&["switch", ¤t]).status.success()); - - let preflight = |reference: &str| -> Value { - let value = execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "preflight", - "command": "git.checkoutPreflight", - "payload": {"root": root, "reference": reference} - })) - .expect("request should encode"), - ); - serde_json::from_str(&value).expect("response should decode") - }; - - // Clean tree: nothing blocks the switch. - let clean = preflight("refs/heads/feature/core"); - assert_eq!(clean["ok"], true, "{clean:?}"); - assert_eq!( - clean["data"]["blockingPaths"], - serde_json::json!([]), - "{clean:?}" - ); - - // Dirty and divergent: preflight names the exact blocking file. - fs::write(root.join("conflict.txt"), "local edit\n").expect("file should be writable"); - let blocked = preflight("refs/heads/feature/core"); - assert_eq!(blocked["ok"], true); - assert_eq!( - blocked["data"]["blockingPaths"], - serde_json::json!(["conflict.txt"]), - "{blocked:?}" - ); - - // Untracked files that the target branch tracks also block a checkout, even - // though they never appear in `git diff HEAD`. - assert!(run(&["stash", "-u"]).status.success()); - fs::write(root.join("conflict.txt"), "untracked local\n").expect("file should be writable"); - let untracked_block = preflight("refs/heads/feature/core"); - assert_eq!( - untracked_block["data"]["blockingPaths"], - serde_json::json!(["conflict.txt"]), - "{untracked_block:?}" - ); - fs::remove_file(root.join("conflict.txt")).expect("file should be removable"); - assert!(run(&["stash", "pop"]).status.success()); - - // A plain checkout is refused rather than clobbering the edit. - let refused = request( - "checkout", - serde_json::json!({ - "reference": "refs/heads/feature/core", - "referenceKind": "local" - }), - ); - assert_ne!(refused["data"]["exitCode"], 0, "{refused:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - current - ); - - // Smart checkout stashes the edit, switches, and restores it. - assert!(run(&["switch", "-c", "feature/smart"]).status.success()); - let smart = request( - "checkout", - serde_json::json!({ - "reference": format!("refs/heads/{current}"), - "referenceKind": "local", - "autoStash": true - }), - ); - assert_eq!(smart["ok"], true); - assert_eq!(smart["data"]["exitCode"], 0, "{smart:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - current - ); - assert_eq!( - fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), - "local edit\n" - ); - assert!( - String::from_utf8_lossy(&run(&["stash", "list"]).stdout).is_empty(), - "smart checkout should consume its stash" - ); - - // Force checkout discards the local edit and lands on the target branch. - let forced = request( - "checkout", - serde_json::json!({ - "reference": "refs/heads/feature/core", - "referenceKind": "local", - "force": true - }), - ); - assert_eq!(forced["ok"], true); - assert_eq!(forced["data"]["exitCode"], 0, "{forced:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - "feature/core" - ); - assert_eq!( - fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), - "on feature\n" - ); - assert!(run(&["switch", ¤t]).status.success()); - - let clone = root - .parent() - .expect("temporary root should have a parent") - .join(format!("lithe-core-clone-{}", std::process::id())); - let clone_result = request( - "clone", - serde_json::json!({ - "remote": root.to_string_lossy(), - "destination": clone.to_string_lossy() - }), - ); - assert_eq!(clone_result["ok"], true); - assert!(clone.join(".git").exists()); - fs::remove_dir_all(clone).expect("temporary clone should be removable"); - - let invalid = request( - "reset", - serde_json::json!({"revision": "HEAD", "mode": "--invalid"}), - ); - assert_eq!(invalid["ok"], false); - assert_eq!(invalid["error"]["code"], "invalid_request"); - - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn stash_restore_conflicts_return_structured_recovery_data() { - let root = temporary_root("git-stash-conflict"); - fs::create_dir_all(&root).expect("temporary repository should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - assert!(run(&["switch", "-qc", "feature"]).status.success()); - fs::write(root.join("shared.txt"), "feature\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "feature edit"]).status.success()); - assert!(run(&["switch", "-q", "main"]).status.success()); - - fs::write(root.join("shared.txt"), "local\n").expect("file should be writable"); - assert!(run(&["stash", "push", "-qm", "restore conflict"]) - .status - .success()); - let stash_reference = - String::from_utf8_lossy(&run(&["stash", "list", "--format=%gd"]).stdout) - .lines() - .next() - .expect("stash reference should exist") - .trim() - .to_string(); - assert!(run(&["switch", "-q", "feature"]).status.success()); - - let write = |operation: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": format!("stash-{operation}"), - "command": "git.write", - "payload": { - "root": root, - "operation": operation, - "reference": stash_reference - } - })) - .expect("stash request should encode"), - )) - .expect("stash response should be JSON") - }; - - let applied = write("stashApply"); - assert_eq!(applied["ok"], true, "{applied:?}"); - assert_eq!(applied["data"]["exitCode"], 1, "{applied:?}"); - assert_eq!( - applied["data"]["stashRestore"]["stashReference"], stash_reference, - "{applied:?}" - ); - assert_eq!( - applied["data"]["stashRestore"]["conflictedPaths"], - serde_json::json!(["shared.txt"]), - "{applied:?}" - ); - - // Clear the index conflict without dropping the saved entry, then verify - // `pop` reports the same structured recovery data. - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - let popped = write("stashPop"); - assert_eq!(popped["ok"], true, "{popped:?}"); - assert_eq!(popped["data"]["exitCode"], 1, "{popped:?}"); - assert_eq!( - popped["data"]["stashRestore"]["stashReference"], stash_reference, - "{popped:?}" - ); - assert_eq!( - popped["data"]["stashRestore"]["conflictedPaths"], - serde_json::json!(["shared.txt"]), - "{popped:?}" - ); - - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - assert!(run(&["stash", "drop", &stash_reference]).status.success()); - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn git_operation_state_reports_and_resolves_a_merge_conflict() { - let root = temporary_root("git-operation"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) - .trim() - .to_string(); - - let state = || -> Value { - let value = execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "operation-state", - "command": "git.operationState", - "payload": {"root": root} - })) - .expect("request should encode"), - ); - serde_json::from_str(&value).expect("response should decode") - }; - let write = |operation: &str| -> Value { - let value = execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "operation-write", - "command": "git.write", - "payload": {"root": root, "operation": operation} - })) - .expect("request should encode"), - ); - serde_json::from_str(&value).expect("response should decode") - }; - - // A settled repository reports no operation and no conflicts. - let idle = state(); - assert_eq!(idle["ok"], true, "{idle:?}"); - assert_eq!(idle["data"]["kind"], "", "{idle:?}"); - assert_eq!(idle["data"]["conflictedPaths"], serde_json::json!([])); - - // Continuing when nothing is in progress is rejected rather than run blindly. - let nothing = write("operationContinue"); - assert_eq!(nothing["ok"], false, "{nothing:?}"); - assert_eq!(nothing["error"]["code"], "invalid_request"); - - // Build two branches that edit the same line, so merging must conflict. - assert!(run(&["switch", "-qc", "feature/conflict"]).status.success()); - fs::write(root.join("shared.txt"), "from feature\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "feature edit"]).status.success()); - assert!(run(&["switch", "-q", ¤t]).status.success()); - fs::write(root.join("shared.txt"), "from main\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "main edit"]).status.success()); - - // Conflicting merges exit non-zero; the point is the state they leave behind. - assert!(!run(&["merge", "--no-edit", "feature/conflict"]) - .status - .success()); - - let conflicted = state(); - assert_eq!(conflicted["ok"], true, "{conflicted:?}"); - assert_eq!(conflicted["data"]["kind"], "merge", "{conflicted:?}"); - assert_eq!( - conflicted["data"]["conflictedPaths"], - serde_json::json!(["shared.txt"]), - "{conflicted:?}" - ); - - // Continuing with the conflict unresolved is refused, so the user cannot - // commit conflict markers by clicking through the banner. - let premature = write("operationContinue"); - assert_eq!(premature["ok"], false, "{premature:?}"); - assert_eq!(premature["error"]["code"], "invalid_request"); - - // A merge has no skip step. - let skip = write("operationSkip"); - assert_eq!(skip["ok"], false, "{skip:?}"); - - // Resolving the file and continuing completes the merge without opening an editor. - fs::write(root.join("shared.txt"), "resolved\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - let finished = write("operationContinue"); - assert_eq!(finished["ok"], true, "{finished:?}"); - assert_eq!(finished["data"]["exitCode"], 0, "{finished:?}"); - - let settled = state(); - assert_eq!(settled["data"]["kind"], "", "{settled:?}"); - assert_eq!(settled["data"]["conflictedPaths"], serde_json::json!([])); - - // Abort restores the pre-merge state of a fresh conflict. - fs::write(root.join("shared.txt"), "main again\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "main again"]).status.success()); - assert!(run(&["switch", "-q", "feature/conflict"]).status.success()); - fs::write(root.join("shared.txt"), "feature again\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "feature again"]).status.success()); - assert!(!run(&["merge", "--no-edit", ¤t]).status.success()); - assert_eq!(state()["data"]["kind"], "merge"); - - let aborted = write("operationAbort"); - assert_eq!(aborted["ok"], true, "{aborted:?}"); - assert_eq!(aborted["data"]["exitCode"], 0, "{aborted:?}"); - assert_eq!(state()["data"]["kind"], ""); - assert_eq!( - fs::read_to_string(root.join("shared.txt")).expect("file should be readable"), - "feature again\n" - ); - - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn git_diff_and_apply_round_trip_a_patch() { - let root = temporary_root("git-diff"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("example.txt"), "before\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - fs::write(root.join("example.txt"), "after\n").expect("file should be writable"); - - let diff = serde_json::json!({ - "id": "diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"], - "contextLines": 80 - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&diff).expect("diff request should encode"), - )) - .expect("diff response should be JSON"); - assert_eq!(response["ok"], true); - assert!(response["data"]["patch"] - .as_str() - .expect("diff output should be text") - .contains("+after")); - assert_eq!(response["data"]["hunks"].as_array().unwrap().len(), 1); - assert!(response["data"]["rows"] - .as_array() - .unwrap() - .iter() - .any(|row| row["kind"] == "changed" && row["right"] == "after")); - - let reference_diff = serde_json::json!({ - "id": "reference-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"], - "reference": "HEAD", - "contextLines": 80 - } - }); - let reference_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&reference_diff).expect("reference diff should encode"), - )) - .expect("reference diff response should be JSON"); - assert_eq!(reference_response["ok"], true); - assert!(reference_response["data"]["patch"] - .as_str() - .expect("reference diff patch should be text") - .contains("+after")); - - let apply = serde_json::json!({ - "id": "apply", - "command": "git.apply", - "payload": { - "root": root, - "patch": response["data"]["patch"], - "mode": "stage" - } - }); - let apply_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&apply).expect("apply request should encode"), - )) - .expect("apply response should be JSON"); - assert_eq!(apply_response["ok"], true); - assert_eq!(apply_response["data"]["exitCode"], 0); - - let status = run(&["status", "--porcelain"]).stdout; - assert_eq!(String::from_utf8_lossy(&status), "M example.txt\n"); - - // Shelve restores the index snapshot and the unstaged worktree delta - // separately. Verify that a file with both kinds of edits returns as MM - // and keeps the final worktree content. - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - let staged_diff = serde_json::json!({ - "id": "staged-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"], - "staged": true - } - }); - let staged_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&staged_diff).expect("staged diff request should encode"), - )) - .expect("staged diff response should be JSON"); - let staged_patch = staged_response["data"]["patch"] - .as_str() - .expect("staged patch should be text") - .to_string(); - - fs::write(root.join("example.txt"), "final\n").expect("file should be writable"); - let working_diff = serde_json::json!({ - "id": "working-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"] - } - }); - let working_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&working_diff).expect("working diff request should encode"), - )) - .expect("working diff response should be JSON"); - let working_patch = working_response["data"]["patch"] - .as_str() - .expect("working patch should be text") - .to_string(); - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - - for (id, patch, mode) in [ - ("restore-index", staged_patch.as_str(), "restoreIndex"), - ("restore-worktree", working_patch.as_str(), "worktree"), - ] { - let apply = serde_json::json!({ - "id": id, - "command": "git.apply", - "payload": {"root": root, "patch": patch, "mode": mode} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&apply).expect("restore apply request should encode"), - )) - .expect("restore apply response should be JSON"); - assert_eq!(response["ok"], true, "{response:?}"); - assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); - } - assert_eq!( - String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), - "MM example.txt\n" - ); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "final\n" - ); - - for (id, patch, mode) in [ - ( - "restore-index-check", - staged_patch.as_str(), - "restoreIndexCheck", - ), - ("worktree-check", working_patch.as_str(), "worktreeCheck"), - ] { - let check = serde_json::json!({ - "id": id, - "command": "git.apply", - "payload": {"root": root, "patch": patch, "mode": mode} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&check).expect("patch check request should encode"), - )) - .expect("patch check response should be JSON"); - assert_eq!(response["ok"], true, "{response:?}"); - assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); - } - - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - fs::write(root.join("new.txt"), "untracked\n").expect("file should be writable"); - let untracked_diff = serde_json::json!({ - "id": "untracked-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["new.txt"], - "untracked": true - } - }); - let untracked_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&untracked_diff).expect("untracked diff request should encode"), - )) - .expect("untracked diff response should be JSON"); - let untracked_patch = untracked_response["data"]["patch"] - .as_str() - .expect("untracked patch should be text") - .to_string(); - fs::remove_file(root.join("new.txt")).expect("file should be removable"); - let untracked_apply = serde_json::json!({ - "id": "untracked-apply", - "command": "git.apply", - "payload": {"root": root, "patch": untracked_patch, "mode": "worktree"} - }); - let untracked_apply_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&untracked_apply) - .expect("untracked apply request should encode"), - )) - .expect("untracked apply response should be JSON"); - assert_eq!(untracked_apply_response["ok"], true); - assert_eq!(untracked_apply_response["data"]["exitCode"], 0); - assert_eq!( - fs::read_to_string(root.join("new.txt")).expect("file should be readable"), - "untracked\n" - ); - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - } - - #[test] - fn git_history_returns_references_and_commit_graph_fields() { - let root = temporary_root("git-history"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("example.txt"), "hello\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) - .trim() - .to_string(); - - let blame_request = serde_json::json!({ - "id": "blame", - "command": "git.blame", - "payload": {"root": root, "path": "example.txt"} - }); - let blame_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&blame_request).expect("blame request should encode"), - )) - .expect("blame response should be JSON"); - assert_eq!(blame_response["ok"], true); - assert_eq!(blame_response["data"]["lines"][0]["line"], 1); - assert_eq!( - blame_response["data"]["lines"][0]["commitHash"], - commit_hash - ); - - let commit_request = serde_json::json!({ - "id": "commit", - "command": "git.commit", - "payload": {"root": root, "commit": commit_hash} - }); - let commit_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&commit_request).expect("commit request should encode"), - )) - .expect("commit response should be JSON"); - assert_eq!(commit_response["ok"], true); - assert_eq!(commit_response["data"]["commit"]["hash"], commit_hash); - - let files_request = serde_json::json!({ - "id": "files", - "command": "git.commitFiles", - "payload": {"root": root, "commit": commit_hash} - }); - let files_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&files_request).expect("commit files request should encode"), - )) - .expect("commit files response should be JSON"); - assert_eq!(files_response["ok"], true); - assert_eq!(files_response["data"]["files"][0]["path"], "example.txt"); - - fs::write(root.join("example.txt"), "changed\n").expect("file should be writable"); - let comparison_request = serde_json::json!({ - "id": "comparison", - "command": "git.comparison", - "payload": {"root": root, "reference": "HEAD"} - }); - let comparison_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&comparison_request).expect("comparison request should encode"), - )) - .expect("comparison response should be JSON"); - assert_eq!(comparison_response["ok"], true); - assert_eq!( - comparison_response["data"]["files"][0]["path"], - "example.txt" - ); - - assert!(run(&["stash", "push", "-qm", "saved"]).status.success()); - let stashes_request = serde_json::json!({ - "id": "stashes", - "command": "git.stashes", - "payload": {"root": root} - }); - let stashes_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&stashes_request).expect("stashes request should encode"), - )) - .expect("stashes response should be JSON"); - assert_eq!(stashes_response["ok"], true); - assert_eq!(stashes_response["data"]["stashes"][0]["message"], "saved"); - - let request = serde_json::json!({ - "id": "history", - "command": "git.history", - "payload": {"root": root, "reference": "HEAD", "limit": 10} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("history request should encode"), - )) - .expect("history response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["commits"][0]["subject"], "initial"); - assert!( - response["data"]["commits"][0]["hash"] - .as_str() - .expect("commit hash should be text") - .len() - >= 7 - ); - assert!(response["data"]["references"] - .as_array() - .expect("references should be an array") - .iter() - .any(|reference| reference["kind"] == "local")); - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - } - - #[test] - fn maven_scan_returns_recursive_shared_project_model() { - let root = temporary_root("maven"); - fs::create_dir_all(root.join("module-a/module-b")).expect("modules should be creatable"); - fs::write( - root.join("pom.xml"), - r#"com.exampledemo1pommodule-adevtrue"#, - ) - .expect("root pom should be writable"); - fs::write( - root.join("module-a/pom.xml"), - r#"onemodule-b"#, - ) - .expect("module pom should be writable"); - fs::write( - root.join("module-a/module-b/pom.xml"), - r#"two"#, - ) - .expect("nested pom should be writable"); - fs::write(root.join("mvnw.cmd"), "@echo off\n").expect("wrapper should be writable"); - - let request = serde_json::json!({ - "id": "maven", - "command": "maven.scan", - "payload": {"root": root} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Maven request should encode"), - )) - .expect("Maven response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["artifactId"], "demo"); - assert_eq!(response["data"]["packaging"], "pom"); - assert_eq!(response["data"]["profiles"][0]["id"], "dev"); - assert_eq!(response["data"]["hasWrapper"], true); - assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); - assert_eq!( - response["data"]["modules"][0]["modules"][0]["relativePath"], - "module-a/module-b" - ); - let diagnostics = serde_json::json!({ - "id": "maven-diagnostics", - "command": "maven.diagnostics", - "payload": { - "root": root, - "output": "[ERROR] src/App.java:[12,4] cannot find symbol\n[ERROR] src/App.java:[12,4] cannot find symbol\n[WARNING] src/App.java:[4] unused import\n" - } - }); - let diagnostics_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&diagnostics).expect("diagnostics request should encode"), - )) - .expect("diagnostics response should be JSON"); - assert_eq!(diagnostics_response["ok"], true); - assert_eq!( - diagnostics_response["data"]["issues"] - .as_array() - .unwrap() - .len(), - 2 - ); - assert_eq!( - diagnostics_response["data"]["issues"][0]["severity"], - "error" - ); - fs::remove_dir_all(root).expect("Maven fixture should be removable"); - } - - #[test] - fn java_core_commands_return_shared_runtime_and_structure_data() { - let root = temporary_root("java"); - fs::create_dir_all(root.join("src/main/java/com/example")) - .expect("Java source should be creatable"); - fs::write( - root.join("src/main/java/com/example/App.java"), - "package com.example;\n@SpringBootApplication\nclass App {\n static void main(String[] args) {}\n}\n", - ) - .expect("Java source should be writable"); - let configurations = serde_json::json!({ - "id": "java-config", - "command": "java.runConfigurations", - "payload": { - "root": root, - "paths": ["src/main/java/com/example/App.java"], - "modulePaths": ["src"] - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&configurations).expect("Java request should encode"), - )) - .expect("Java response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!( - response["data"]["mainClasses"][0]["qualifiedName"], - "com.example.App" - ); - assert_eq!(response["data"]["configurations"][0]["kind"], "springBoot"); - assert_eq!(response["data"]["configurations"][0]["modulePath"], "src"); - - let structure = serde_json::json!({ - "id": "java-structure", - "command": "java.structure", - "payload": { - "source": "import a.A;\nimport b.B;\ninterface Service {}\n" - } - }); - let structure_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&structure).expect("Java structure request should encode"), - )) - .expect("Java structure response should be JSON"); - assert_eq!(structure_response["ok"], true); - assert_eq!( - structure_response["data"]["foldRegions"][0]["kind"], - "imports" - ); - assert_eq!( - structure_response["data"]["implementationMarkers"][0]["direction"], - "down" - ); - let swift_structure = serde_json::json!({ - "id": "swift-structure", - "command": "java.structure", - "payload": { - "source": "struct Demo {\n func run() {\n if ready {\n work()\n }\n }\n}\n" - } - }); - let swift_structure_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&swift_structure) - .expect("Swift structure request should encode"), - )) - .expect("Swift structure response should be JSON"); - let swift_folds = swift_structure_response["data"]["foldRegions"] - .as_array() - .expect("Swift structure should return fold regions"); - assert!(swift_folds.iter().any(|fold| { - fold["startLine"] == 0 && fold["endLine"] == 6 && fold["kind"] == "type" - })); - assert!(swift_folds.iter().any(|fold| { - fold["startLine"] == 1 && fold["endLine"] == 5 && fold["kind"] == "method" - })); - let code_vision = serde_json::json!({ - "id": "java-vision", - "command": "java.codeVision", - "payload": { - "root": root, - "targetPath": "src/main/java/com/example/App.java", - "paths": ["src/main/java/com/example/App.java"] - } - }); - let vision_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&code_vision).expect("code vision request should encode"), - )) - .expect("code vision response should be JSON"); - assert_eq!(vision_response["ok"], true); - assert!(vision_response["data"]["hints"] - .as_array() - .unwrap() - .iter() - .any(|hint| hint["symbol"] == "App")); - let class_name = serde_json::json!({ - "id": "java-class", - "command": "java.className", - "payload": {"source": "package com.example;\nclass App {}", "simpleName": "App"} - }); - let class_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&class_name).expect("class name request should encode"), - )) - .expect("class name response should be JSON"); - assert_eq!(class_response["data"]["className"], "com.example.App"); - let definition = serde_json::json!({ - "id": "java-definition", - "command": "java.sourceDefinition", - "payload": { - "source": "class App {\n void run() {}\n}", - "declarationName": "App", - "memberName": "run" - } - }); - let definition_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&definition).expect("definition request should encode"), - )) - .expect("definition response should be JSON"); - assert_eq!(definition_response["data"]["line"], 1); - let server_port = serde_json::json!({ - "id": "java-port", - "command": "java.serverPort", - "payload": {"content": "server:\n port: 8080\n", "fileExtension": "yml"} - }); - let port_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&server_port).expect("server port request should encode"), - )) - .expect("server port response should be JSON"); - assert_eq!(port_response["data"]["port"], 8080); - fs::remove_dir_all(root).expect("Java fixture should be removable"); - } - - #[test] - fn git_conflict_markers_ignore_markdown_headings() { - let root = temporary_root("git-markers"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - let markers = || -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "conflict-markers", - "command": "git.conflictMarkers", - "payload": {"root": root} - })) - .expect("request should encode"), - )) - .expect("conflict marker response should be JSON") - }; - - // A Markdown setext heading underline looks exactly like the middle of a - // conflict block, so matching a bare `=======` would flag ordinary docs. - fs::write(root.join("doc.md"), "Title\n=======\n\nbody\n") - .expect("file should be writable"); - assert!(run(&["add", "doc.md"]).status.success()); - let clean = markers(); - assert_eq!(clean["ok"], true); - assert_eq!( - clean["data"]["paths"].as_array().unwrap().len(), - 0, - "a Markdown heading is not a conflict: {clean}" - ); - - // Only files carrying the opening or closing marker are real conflicts. - fs::write( - root.join("code.txt"), - "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\n", - ) - .expect("file should be writable"); - // The diff3 style adds a `|||||||` base section, which also counts. - fs::write( - root.join("diff3.txt"), - "x\n<<<<<<< HEAD\na\n||||||| base\nb\n=======\nc\n>>>>>>> other\n", - ) - .expect("file should be writable"); - assert!(run(&["add", "."]).status.success()); - - let found = markers(); - let paths = found["data"]["paths"].as_array().unwrap(); - assert_eq!(paths.len(), 2, "{found}"); - assert_eq!(paths[0], "code.txt"); - assert_eq!(paths[1], "diff3.txt"); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - #[test] - fn git_integration_preflight_separates_merge_overlap_from_rebase_strictness() { - let root = temporary_root("git-integration"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); - assert!(run(&["add", "."]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - // A side branch that only ever touches shared.txt. - assert!(run(&["switch", "-qc", "feature"]).status.success()); - fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "incoming"]).status.success()); - assert!(run(&["switch", "-q", "main"]).status.success()); - // Move main forward so the branches genuinely diverge. - fs::write(root.join("main.txt"), "main\n").expect("file should be writable"); - assert!(run(&["add", "main.txt"]).status.success()); - assert!(run(&["commit", "-qm", "main side"]).status.success()); - - let preflight = |operation: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "integration-preflight", - "command": "git.integrationPreflight", - "payload": { - "root": root, - "reference": "refs/heads/feature", - "operation": operation - } - })) - .expect("request should encode"), - )) - .expect("integration preflight response should be JSON") - }; - - // A clean tree blocks neither operation. - assert_eq!( - preflight("merge")["data"]["blockingPaths"] - .as_array() - .unwrap() - .len(), - 0 - ); - assert_eq!( - preflight("rebase")["data"]["blockingPaths"] - .as_array() - .unwrap() - .len(), - 0 - ); - - // Dirty a file the incoming branch never touches. Git lets a merge proceed - // here but still refuses a rebase, so the two must report differently. - fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); - - let merge = preflight("merge"); - assert_eq!(merge["ok"], true); - assert_eq!( - merge["data"]["blockingPaths"].as_array().unwrap().len(), - 0, - "an unrelated edit should not block a merge: {merge}" - ); - assert_eq!(merge["data"]["blocksEntirely"], false); - - let rebase = preflight("rebase"); - assert_eq!(rebase["data"]["blockingPaths"][0], "other.txt"); - assert_eq!(rebase["data"]["blocksEntirely"], true); - - // Now dirty the file the merge would write; that one does block it. - fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); - let overlapping = preflight("merge"); - assert_eq!(overlapping["data"]["blockingPaths"][0], "shared.txt"); - assert_eq!( - overlapping["data"]["blockingPaths"] - .as_array() - .unwrap() - .len(), - 1, - "only the overlapping file blocks: {overlapping}" - ); - - // An unknown operation is rejected rather than guessed at. - let invalid = serde_json::from_str::(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "integration-preflight", - "command": "git.integrationPreflight", - "payload": { - "root": root, - "reference": "refs/heads/feature", - "operation": "graft" - } - })) - .expect("request should encode"), - )) - .expect("response should be JSON"); - assert_eq!(invalid["ok"], false); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - #[test] - fn git_integration_preflight_scopes_cherry_pick_to_the_replayed_commit() { - let root = temporary_root("git-cherry-pick"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); - assert!(run(&["add", "."]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - // A side branch of two commits. Only the second one touches shared.txt, so - // picking it must consider that file alone rather than the whole branch. - assert!(run(&["switch", "-qc", "feature"]).status.success()); - fs::write(root.join("early.txt"), "early\n").expect("file should be writable"); - assert!(run(&["add", "early.txt"]).status.success()); - assert!(run(&["commit", "-qm", "earlier work"]).status.success()); - fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "touches shared"]).status.success()); - let pick = String::from_utf8(run(&["rev-parse", "HEAD"]).stdout) - .expect("a revision should be UTF-8"); - let pick = pick.trim().to_string(); - assert!(run(&["switch", "-q", "main"]).status.success()); - - let preflight = |operation: &str, reference: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "integration-preflight", - "command": "git.integrationPreflight", - "payload": { - "root": root, - "reference": reference, - "operation": operation - } - })) - .expect("request should encode"), - )) - .expect("integration preflight response should be JSON") - }; - - // An edit to a file the picked commit never touches is not in its way, the - // same rule a merge follows and unlike a rebase. - fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); - for operation in ["cherryPick", "revert"] { - let clear = preflight(operation, &pick); - assert_eq!(clear["ok"], true, "{operation} should succeed: {clear}"); - assert_eq!( - clear["data"]["blockingPaths"].as_array().unwrap().len(), - 0, - "an unrelated edit should not block {operation}: {clear}" - ); - assert_eq!(clear["data"]["blocksEntirely"], false); - } - - // Dirtying the file that commit rewrites does block it. - fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); - let blocked = preflight("cherryPick", &pick); - assert_eq!(blocked["data"]["blockingPaths"][0], "shared.txt"); - assert_eq!( - blocked["data"]["blockingPaths"].as_array().unwrap().len(), - 1, - "only the file the commit writes blocks it: {blocked}" - ); - - // The branch tip as a whole also adds early.txt, but picking the single - // commit must not inherit that; a merge of the same ref would report it. - let merge = preflight("merge", "refs/heads/feature"); - let merge_blocking = merge["data"]["blockingPaths"].as_array().unwrap(); - assert!( - merge_blocking.iter().any(|path| path == "shared.txt"), - "the merge shares the overlap: {merge}" - ); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - #[test] - fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { - let root = temporary_root("git-pull"); - let upstream = root.join("upstream"); - let work = root.join("work"); - fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); - - let git = |directory: &std::path::Path, arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(directory) - .output() - .expect("git should be available") - }; - let identify = |directory: &std::path::Path| { - assert!(git(directory, &["config", "core.autocrlf", "false"]) - .status - .success()); - assert!( - git(directory, &["config", "user.email", "test@example.com"]) - .status - .success() - ); - assert!(git(directory, &["config", "user.name", "Lithe Test"]) - .status - .success()); - }; - - assert!(git(&upstream, &["init", "-q", "-b", "main"]) - .status - .success()); - identify(&upstream); - fs::write(upstream.join("shared.txt"), "base\n").expect("file should be writable"); - assert!(git(&upstream, &["add", "shared.txt"]).status.success()); - assert!(git(&upstream, &["commit", "-qm", "initial"]) - .status - .success()); - - assert!(git( - &root, - &[ - "clone", - "-q", - "-c", - "core.autocrlf=false", - "upstream", - "work" - ] - ) - .status - .success()); - identify(&work); - - let preflight = || -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "pull-preflight", - "command": "git.pullPreflight", - "payload": {"root": work} - })) - .expect("request should encode"), - )) - .expect("pull preflight response should be JSON") - }; - - // A fresh clone is level with its upstream, so nothing needs deciding. - let clean = preflight(); - assert_eq!(clean["ok"], true); - assert_eq!(clean["data"]["upstream"], "origin/main"); - assert_eq!(clean["data"]["diverged"], false); - assert_eq!(clean["data"]["ahead"], 0); - assert_eq!(clean["data"]["behind"], 0); - - // Commit on both sides so neither can fast-forward past the other. - fs::write(upstream.join("remote.txt"), "remote\n").expect("file should be writable"); - assert!(git(&upstream, &["add", "remote.txt"]).status.success()); - assert!(git(&upstream, &["commit", "-qm", "remote"]) - .status - .success()); - fs::write(work.join("local.txt"), "local\n").expect("file should be writable"); - assert!(git(&work, &["add", "local.txt"]).status.success()); - assert!(git(&work, &["commit", "-qm", "local"]).status.success()); - assert!(git(&work, &["fetch", "-q"]).status.success()); - - let diverged = preflight(); - assert_eq!(diverged["data"]["diverged"], true); - assert_eq!(diverged["data"]["ahead"], 1); - assert_eq!(diverged["data"]["behind"], 1); - - let pull = |mode: Option<&str>| -> Value { - let mut payload = serde_json::json!({"root": work, "operation": "pull"}); - if let Some(mode) = mode { - payload["mode"] = serde_json::json!(mode); - } - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "pull", - "command": "git.write", - "payload": payload - })) - .expect("request should encode"), - )) - .expect("pull response should be JSON") - }; - - // The default refuses a divergent history rather than inventing a merge. - let refused = pull(None); - assert_ne!(refused["data"]["exitCode"], 0); - - // Rebase replays the local commit on top, leaving a linear history. - let rebased = pull(Some("rebase")); - assert_eq!(rebased["data"]["exitCode"], 0, "{rebased}"); - - let settled = preflight(); - assert_eq!(settled["data"]["diverged"], false); - assert_eq!(settled["data"]["behind"], 0); - assert_eq!(settled["data"]["ahead"], 1); - - // An unknown strategy is rejected before Git ever runs. - let invalid = pull(Some("squash")); - assert_eq!(invalid["ok"], false); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - /// The v1 -> v2 rewrite must not change a single byte of the emitted command - /// line. Values are asserted literally rather than recomputed, so a future - /// refactor that silently drops an argument fails here instead of at runtime. - #[test] - fn migrated_v1_documents_produce_identical_launch_arguments() { - let root = temporary_root("run-config-migration"); - fs::create_dir_all(root.join("backend/src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("backend/pom.xml"), "").unwrap(); - fs::write( - root.join("backend/src/main/java/com/example/App.java"), - "package com.example; class App { public static void main(String[] args) {} }", - ) - .unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{ - "id":"spring:com.example.App", - "name":"App", - "type":"spring-boot.maven", - "module":"backend", - "workingDirectory":".", - "mainClass":"com.example.App", - "jvmArguments":["-Xmx2g"], - "programArguments":["--dev"], - "mavenProfiles":["local"], - "toolchains":{"java":"project-jdk","maven":"project-maven"} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "migrated-plan", - "command": "runConfig.createLaunchPlan", - "payload": { - "root": root, - "configurationId": "spring:com.example.App", - "debugPort": 5005 - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true); - assert_eq!( - plan["data"]["arguments"], - serde_json::json!([ - "-B", - "-ntp", - "-pl", - "backend", - "-P", - "local", - "-Dspring-boot.run.main-class=com.example.App", - "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5005 -Duser.language=en -Duser.country=US -Xmx2g", - "-Dspring-boot.run.arguments=--dev", - "spring-boot:run" - ]) - ); - assert_eq!(plan["data"]["workingDirectory"], "."); - assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); - - fs::remove_dir_all(root).unwrap(); - } - - /// `project.json` and the toolchain files sit under `.lithe` and carry their - /// own `version: 1`, unrelated to the run-configuration schema. Migration - /// must not touch them, or resolve rejects a perfectly valid project. - #[test] - fn migration_leaves_sidecar_documents_at_their_own_version() { - let root = temporary_root("run-config-sidecar"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/project.json"), - r#"{"version":1,"defaultRunConfiguration":"current-file"}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/toolchains/requirements.json"), - r#"{"version":1,"toolchains":{}}"#, - ) - .unwrap(); - - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "sidecar", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true, "{resolved}"); - assert_eq!(resolved["data"]["version"], 2); - assert_eq!(resolved["data"]["defaultRunConfiguration"], "current-file"); - - fs::remove_dir_all(root).unwrap(); - } - - /// A non-Java service must reach a launch plan without acquiring a Java - /// toolchain or a JAVA_HOME it has no use for. - #[test] - fn process_configurations_launch_without_java_assumptions() { - let root = temporary_root("run-config-process"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join("frontend")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"npm:dev", - "name":"web dev", - "provider":"npm.script", - "execution":"service", - "confidence":"declared", - "command":"npm", - "args":["run","dev"], - "cwd":"frontend", - "env":{"PORT":"3000"}, - "toolchains":{} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "process-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "npm:dev"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["command"], "npm"); - assert!(plan["data"]["executable"]["toolchain"].is_null()); - assert_eq!(plan["data"]["arguments"], serde_json::json!(["run", "dev"])); - assert_eq!(plan["data"]["workingDirectory"], "frontend"); - assert_eq!(plan["data"]["env"]["PORT"], "3000"); - assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn toolchain_backed_process_uses_the_generic_runtime_binding() { - let root = temporary_root("run-config-go-toolchain"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"go:api","name":"Go API","provider":"go.main", - "execution":"application","args":["run","./cmd/api"],"cwd":".", - "env":{"APP_ENV":"dev"},"toolchains":{"runtime":"project-go"} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "go-toolchain-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "go:api"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["toolchain"], "project-go"); - assert!(plan["data"]["executable"]["command"].is_null()); - assert_eq!( - plan["data"]["arguments"], - serde_json::json!(["run", "./cmd/api"]) - ); - assert_eq!(plan["data"]["env"]["APP_ENV"], "dev"); - assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn pure_go_generation_does_not_require_java_or_add_java_current_file() { - let root = temporary_root("pure-go-no-jdk"); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); - fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); - - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-pure-go", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["go.mod", "main.go"], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["ok"], true, "{generated}"); - let configurations = generated["data"]["generated"]["configurations"] - .as_array() - .unwrap(); - assert!(configurations - .iter() - .any(|value| value["provider"] == "go.main")); - assert!(!configurations - .iter() - .any(|value| value["id"] == "current-file")); - assert!(generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn multi_language_generation_declares_runtime_requirements_and_versions() { - let root = temporary_root("generic-toolchain-requirements"); - for directory in ["python", "web", "worker/src"] { - fs::create_dir_all(root.join(directory)).unwrap(); - } - fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); - fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); - fs::write( - root.join("python/pyproject.toml"), - "[project]\nname = \"api\"\nrequires-python = \">=3.12\"\n[project.scripts]\napi = \"api:main\"\n", - ) - .unwrap(); - fs::write( - root.join("web/package.json"), - r#"{"engines":{"node":">=22.4"},"scripts":{"dev":"vite"}}"#, - ) - .unwrap(); - fs::write( - root.join("worker/Cargo.toml"), - "[package]\nname = \"worker\"\nversion = \"0.1.0\"\nrust-version = \"1.82\"\n", - ) - .unwrap(); - fs::write(root.join("worker/src/main.rs"), "fn main() {}\n").unwrap(); - - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-generic-requirements", - "command": "runConfig.generate", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["ok"], true, "{generated}"); - let requirements = &generated["data"]["toolchainRequirements"]["toolchains"]; - for (id, kind, version) in [ - ("project-go", "go", "1.24"), - ("project-python", "python", "3.12"), - ("project-node", "node", "22.4"), - ("project-cargo", "rust", "1.82"), - ] { - assert_eq!(requirements[id]["type"], kind, "{requirements}"); - assert_eq!( - requirements[id]["minimumVersion"], version, - "{requirements}" - ); - } - assert!(requirements["project-jdk"].is_null(), "{requirements}"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn unknown_provider_without_an_executable_never_falls_into_maven() { - let root = temporary_root("run-config-unknown-provider"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"zig:app","name":"Zig App","provider":"zig.main", - "args":[],"cwd":".","toolchains":{} - }]}"#, - ) - .unwrap(); - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "unknown-provider-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "zig:app"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], false, "{plan}"); - assert_eq!(plan["error"]["code"], "invalid_request"); - assert!(plan["error"]["message"] - .as_str() - .unwrap_or("") - .contains("command or runtime toolchain")); - - fs::remove_dir_all(root).unwrap(); - } - - /// Generic editor options must patch the common process shape. Writing - /// them into extensions.maven makes the UI appear to save successfully - /// while Go/Python/Node launch plans continue using the old arguments. - #[test] - fn process_options_update_common_arguments_and_environment() { - let root = temporary_root("run-config-process-options"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join("backend")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"python:api","name":"API","provider":"python.script", - "command":"python3","args":["app.py"],"cwd":".","toolchains":{} - }]}"#, - ) - .unwrap(); - - let updated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "update-process-options", - "command": "runConfig.updateOptions", - "payload": { - "root": root, - "scope": "local", - "configurationId": "python:api", - "workingDirectory": "backend", - "arguments": "app.py --port 9000", - "environment": {"APP_ENV": "test"} - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(updated["ok"], true, "{updated}"); - let document: Value = serde_json::from_str( - updated["data"]["document"] - .as_str() - .expect("document string"), - ) - .unwrap(); - let patch = &document["configurations"][0]; - assert_eq!( - patch["args"], - serde_json::json!(["app.py", "--port", "9000"]) - ); - assert_eq!(patch["env"]["APP_ENV"], "test"); - assert!(patch["extensions"]["maven"].is_null()); - - fs::write( - root.join(".lithe/run/local.json"), - updated["data"]["document"].as_str().unwrap(), - ) - .unwrap(); - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "updated-process-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "python:api"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!( - plan["data"]["arguments"], - serde_json::json!(["app.py", "--port", "9000"]) - ); - assert_eq!(plan["data"]["env"]["APP_ENV"], "test"); - - fs::remove_dir_all(root).unwrap(); - } - - /// An absolute or relative path would let a project manifest point the IDE - /// at an executable of its choosing. Commands resolve on PATH only. - #[test] - fn process_configurations_reject_path_qualified_commands() { - let root = temporary_root("run-config-process-path"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"evil","name":"evil","provider":"shell.command", - "command":"../../../usr/bin/curl","args":[],"cwd":".","toolchains":{} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "evil-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "evil"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], false); - assert_eq!(plan["error"]["code"], "invalid_request"); - - fs::remove_dir_all(root).unwrap(); - } - - /// Builds a project that mixes six ecosystems in one tree, including the - /// traps that break naive detectors: a lockfile that is not npm's, a - /// `node_modules` full of decoy manifests, and a Go command in a - /// subdirectory with no manifest of its own. - fn multi_language_project(root: &Path) { - for directory in ["frontend/node_modules/decoy", "api", "cmd/gateway"] { - fs::create_dir_all(root.join(directory)).unwrap(); - } - fs::write( - root.join("frontend/package.json"), - r#"{"name":"web","scripts":{"dev":"vite","build":"vite build"}}"#, - ) - .unwrap(); - fs::write(root.join("frontend/pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); - fs::write( - root.join("frontend/node_modules/decoy/package.json"), - r#"{"scripts":{"dev":"should-never-appear"}}"#, - ) - .unwrap(); - fs::write( - root.join("api/pyproject.toml"), - "[tool.poetry]\nname = \"api\"\n[tool.poetry.scripts]\napi-server = \"api.main:run\"\n", - ) - .unwrap(); - fs::write( - root.join("api/main.py"), - "from fastapi import FastAPI\napp = FastAPI()\n", - ) - .unwrap(); - fs::write(root.join("go.mod"), "module example.com/gw\ngo 1.22\n").unwrap(); - fs::write( - root.join("cmd/gateway/main.go"), - "package main\nfunc main() {}\n", - ) - .unwrap(); - fs::write( - root.join("docker-compose.yml"), - "services:\n db:\n image: postgres\n cache:\n image: redis\n", - ) - .unwrap(); - fs::write( - root.join("Procfile"), - "worker: python worker/run.py\nweb: gunicorn api.main:app\n", - ) - .unwrap(); - fs::write( - root.join("Makefile"), - "run:\n\techo run\nclean:\n\techo clean\n", - ) - .unwrap(); - } - - fn generated_configurations(root: &Path) -> Vec { - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate", - "command": "runConfig.generate", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - response["data"]["generated"]["configurations"] - .as_array() - .cloned() - .unwrap() - } - - /// The headline behaviour: one project, six ecosystems, every service found - /// without the user configuring anything. - #[test] - fn detectors_find_services_across_unrelated_ecosystems() { - let root = temporary_root("detect-multi"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - - let ids = generated_configurations(&root) - .iter() - .map(|item| item["id"].as_str().unwrap().to_string()) - .collect::>(); - - for expected in [ - "npm.script:frontend/dev", - "python.script:api/api-server", - "python.uvicorn:api/main", - "go.command:cmd/gateway/gateway", - "compose.service:db", - "compose.stack:compose up", - "procfile.process:web", - "make.target:run", - ] { - assert!( - ids.contains(&expected.to_string()), - "missing {expected} in {ids:?}" - ); - } - - fs::remove_dir_all(root).unwrap(); - } - - /// A dependency tree contains thousands of manifests. Descending into it - /// would both bury the real services and make project open unusably slow. - #[test] - fn detectors_never_descend_into_dependency_directories() { - let root = temporary_root("detect-prune"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - - let sources = generated_configurations(&root) - .iter() - .filter_map(|item| item["source"].as_str().map(str::to_string)) - .collect::>(); - - assert!( - !sources.iter().any(|source| source.contains("node_modules")), - "{sources:?}" - ); - - fs::remove_dir_all(root).unwrap(); - } - - /// Running `npm run dev` in a pnpm workspace fails at spawn time with an - /// error that points nowhere useful, so the lockfile decides the command. - #[test] - fn npm_detector_uses_the_package_manager_the_lockfile_names() { - let root = temporary_root("detect-pnpm"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - - let dev = generated_configurations(&root) - .into_iter() - .find(|item| item["id"] == "npm.script:frontend/dev") - .unwrap(); - - assert_eq!(dev["command"], "pnpm"); - assert_eq!(dev["args"], serde_json::json!(["run", "dev"])); - assert_eq!(dev["cwd"], "frontend"); - assert_eq!(dev["execution"], "service"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn npm_detector_inherits_the_workspace_package_manager() { - let root = temporary_root("detect-pnpm-workspace"); - fs::create_dir_all(root.join("apps/web")).unwrap(); - fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); - fs::write( - root.join("apps/web/package.json"), - r#"{"scripts":{"dev":"vite"}}"#, - ) - .unwrap(); - - let dev = generated_configurations(&root) - .into_iter() - .find(|item| item["id"] == "npm.script:apps/web/dev") - .unwrap(); - assert_eq!(dev["command"], "pnpm"); - assert_eq!(dev["execution"], "service"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn detectors_preserve_application_service_and_task_semantics() { - let root = temporary_root("detect-execution-semantics"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - let configurations = generated_configurations(&root); - let execution = |id: &str| { - configurations - .iter() - .find(|item| item["id"] == id) - .and_then(|item| item["execution"].as_str()) - }; - - assert_eq!(execution("npm.script:frontend/dev"), Some("service")); - assert_eq!(execution("npm.script:frontend/build"), Some("task")); - assert_eq!( - execution("python.script:api/api-server"), - Some("application") - ); - assert_eq!( - execution("go.command:cmd/gateway/gateway"), - Some("application") - ); - assert_eq!(execution("compose.stack:compose up"), Some("service")); - - fs::remove_dir_all(root).unwrap(); - } - - /// Detected entries are process-based, so they must survive the same launch - /// path as any other configuration without acquiring Java assumptions. - #[test] - fn detected_services_produce_runnable_launch_plans() { - let root = temporary_root("detect-launch"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - let generated = serde_json::json!({ - "version": 2, - "configurations": generated_configurations(&root) - }); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - serde_json::to_string(&generated).unwrap(), - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "npm.script:frontend/dev"} - }) - .to_string(), - )) - .unwrap(); - - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["command"], "pnpm"); - assert!(plan["data"]["executable"]["toolchain"].is_null()); - assert_eq!(plan["data"]["workingDirectory"], "frontend"); - assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - /// Ids are the join key for the team and local override layers. A detector - /// that renamed a Java configuration would silently detach every override - /// written against it, with no error anywhere. - #[test] - fn detectors_never_claim_an_id_the_java_scan_already_produced() { - let root = temporary_root("detect-no-clobber"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write( - root.join("src/Main.java"), - "class Main { public static void main(String[] args) {} }", - ) - .unwrap(); - - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-with-java", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/Main.java"]} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - let ids = response["data"]["generated"]["configurations"] - .as_array() - .unwrap() - .iter() - .map(|item| item["id"].as_str().unwrap().to_string()) - .collect::>(); - let mut unique = ids.clone(); - unique.sort(); - unique.dedup(); - - assert_eq!(ids.len(), unique.len(), "duplicate ids in {ids:?}"); - assert!(ids.contains(&"current-file".to_string())); - - fs::remove_dir_all(root).unwrap(); - } - - /// A Java project that also ships a frontend must gain the frontend's - /// services without any Java configuration changing id. Ids are the join key - /// for the team and local layers, so a shifted id detaches every override - /// silently -- there is no error to notice. - #[test] - fn detectors_extend_a_java_project_without_disturbing_its_configurations() { - let root = temporary_root("detect-java-mixed"); - let module = root.join("backend-api/src/main/java/com/demo"); - fs::create_dir_all(&module).unwrap(); - fs::create_dir_all(root.join("frontend-web")).unwrap(); - fs::write( - root.join("pom.xml"), - "backend-api", - ) - .unwrap(); - fs::write(root.join("backend-api/pom.xml"), "").unwrap(); - fs::write( - module.join("BackendApplication.java"), - "package com.demo;\n@SpringBootApplication\npublic class BackendApplication { public static void main(String[] a) {} }\n", - ) - .unwrap(); - fs::write( - root.join("frontend-web/package.json"), - r#"{"name":"web","scripts":{"dev":"vite"}}"#, - ) - .unwrap(); - - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate", - "command": "runConfig.generate", - "payload": { - "root": root, - "paths": ["backend-api/src/main/java/com/demo/BackendApplication.java"] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - let configurations = response["data"]["generated"]["configurations"] - .as_array() - .unwrap(); - let ids = configurations - .iter() - .map(|item| item["id"].as_str().unwrap().to_string()) - .collect::>(); - - assert!( - ids.contains(&"spring:com.demo.BackendApplication".to_string()), - "{ids:?}" - ); - assert!( - ids.contains(&"npm.script:frontend-web/dev".to_string()), - "{ids:?}" - ); - // The Java entries stay toolchain-backed; only the detected ones are - // process-based. A regression here would send npm through Maven. - let java = configurations - .iter() - .find(|item| item["id"] == "spring:com.demo.BackendApplication") - .unwrap(); - assert!(java["command"].is_null()); - assert_eq!(java["toolchains"]["maven"], "project-maven"); - - fs::remove_dir_all(root).unwrap(); - } -} +mod tests; diff --git a/rust/lithe-core/src/lsp.rs b/rust/lithe-core/src/lsp.rs deleted file mode 100644 index 31184324..00000000 --- a/rust/lithe-core/src/lsp.rs +++ /dev/null @@ -1,3583 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use crate::error::{CoreError, ErrorCode}; - -const BUILTIN_LANGUAGE_PROVIDERS: &str = include_str!("../resources/lsp/language-providers.json"); - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspProviderCatalog { - pub version: u32, - pub providers: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspProviderConfigDiagnostic { - pub path: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspProviderDescriptor { - pub id: String, - pub display_name: String, - pub file_extensions: Vec, - pub file_names: Vec, - pub file_name_prefixes: Vec, - pub capabilities: Vec, - pub activation_policy: LspActivationPolicy, - pub language_id: Option, - pub language_ids_by_extension: BTreeMap, - pub language_ids_by_file_name: BTreeMap, - pub language_server_launch: Option, - pub language_server_installation: Option, -} - -#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LspServerLaunchDescriptor { - pub executable_names: Vec, - #[serde(default)] - pub arguments: Vec, - #[serde(default)] - pub environment: BTreeMap, - #[serde(default)] - pub initialization_options: Option, -} - -#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LspServerInstallationDescriptor { - #[serde(default)] - pub homebrew_formula: Option, - #[serde(default, rename = "officialDownloadURL")] - pub official_download_url: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum LspProviderCapability { - Run, - LanguageServer, - DebugAdapter, - Formatting, - Testing, -} - -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum LspActivationPolicy { - OnDemand, - Always, -} - -impl Default for LspActivationPolicy { - fn default() -> Self { - Self::OnDemand - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct LspProviderConfigDocument { - #[serde(default, rename = "$schema")] - _schema: Option, - #[serde(default = "default_config_version")] - version: u32, - #[serde(default)] - providers: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct LspProviderPatch { - id: String, - #[serde(default)] - display_name: Option, - #[serde(default)] - file_extensions: Option>, - #[serde(default)] - file_names: Option>, - #[serde(default)] - file_name_prefixes: Option>, - #[serde(default)] - capabilities: Option>, - #[serde(default)] - activation_policy: Option, - #[serde(default)] - language_id: Option, - #[serde(default)] - language_ids_by_extension: Option>, - #[serde(default)] - language_ids_by_file_name: Option>, - #[serde(default)] - language_server_launch: Option, - #[serde(default)] - language_server_installation: Option, - #[serde(default)] - disabled: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApplyTextEditsRequest { - pub text: String, - #[serde(default)] - pub edits: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LspTextEdit { - pub range: LspRange, - pub new_text: String, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LspRange { - pub start: LspPosition, - pub end: LspPosition, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LspPosition { - pub line: i64, - pub utf16_column: i64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TextResponse { - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlainSnippetRequest { - pub value: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinRequest { - pub file_path: String, - pub text: String, - pub position: LspPosition, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinNavigationRequest { - pub file_path: String, - pub text: String, - pub position: LspPosition, - pub method: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinCompletionResponse { - pub items: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinCompletionItem { - pub label: String, - pub insert_text: String, - pub kind: Option, - pub detail: Option, - pub text_edit: LspTextEditResponse, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspTextEditResponse { - pub range: LspRangeResponse, - pub new_text: String, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspRangeResponse { - pub start: LspPositionResponse, - pub end: LspPositionResponse, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspPositionResponse { - pub line: i64, - pub utf16_column: i64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinHoverResponse { - pub hover: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinHover { - pub contents: String, - pub is_markdown: bool, - pub range: LspRangeResponse, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinNavigationResponse { - pub locations: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuiltinLocation { - pub file_path: String, - pub range: LspRangeResponse, - pub is_read_only: bool, - pub display_path: Option, -} - -#[derive(Debug, Clone)] -struct IdentifierOccurrence { - value: String, - start: usize, - end: usize, - range: LspRangeResponse, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspClientState { - #[serde(default = "default_next_request_id")] - pub next_request_id: u64, - #[serde(default)] - pub initialized: bool, - #[serde(default)] - pub shutdown_requested: bool, - #[serde(default)] - pub server_capabilities: Vec, - #[serde(default)] - pub open_documents: BTreeMap, - #[serde(default)] - pub pending_requests: BTreeMap, - #[serde(default)] - pub diagnostics: BTreeMap>, -} - -impl Default for LspClientState { - fn default() -> Self { - Self { - next_request_id: default_next_request_id(), - initialized: false, - shutdown_requested: false, - server_capabilities: Vec::new(), - open_documents: BTreeMap::new(), - pending_requests: BTreeMap::new(), - diagnostics: BTreeMap::new(), - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspClientDocument { - pub uri: String, - pub language_id: String, - pub version: i64, - pub text: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspClientDiagnostic { - pub range: LspRangeResponse, - pub severity: Option, - pub message: String, - pub source: Option, - pub code: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientInitializeRequest { - #[serde(default)] - pub state: LspClientState, - pub root_uri: String, - #[serde(default)] - pub process_id: Option, - #[serde(default)] - pub initialization_options: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientOpenDocumentRequest { - #[serde(default)] - pub state: LspClientState, - pub uri: String, - pub language_id: String, - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientChangeDocumentRequest { - #[serde(default)] - pub state: LspClientState, - pub uri: String, - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientCloseDocumentRequest { - #[serde(default)] - pub state: LspClientState, - pub uri: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientShutdownRequest { - #[serde(default)] - pub state: LspClientState, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientFeatureRequest { - #[serde(default)] - pub state: LspClientState, - pub uri: String, - pub method: String, - #[serde(default)] - pub position: Option, - #[serde(default)] - pub new_name: Option, - #[serde(default)] - pub range: Option, - #[serde(default)] - pub diagnostics: Vec, - #[serde(default)] - pub completion_item: Option, - #[serde(default)] - pub code_action: Option, - #[serde(default)] - pub command: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientApplyServerMessageRequest { - #[serde(default)] - pub state: LspClientState, - pub message: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FrameMessageRequest { - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FrameMessageResponse { - pub frame: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ParseServerMessagesRequest { - #[serde(default)] - pub buffer: Vec, - #[serde(default)] - pub chunk: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ParseServerMessagesResponse { - pub buffer: Vec, - pub messages: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspClientResponse { - pub state: LspClientState, - pub messages: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LspClientEvent { - pub kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub method: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub uri: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub diagnostics: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { - let catalog = provider_catalog(workspace_root); - serde_json::to_string(&catalog) - .unwrap_or_else(|_| "{\"version\":1,\"providers\":[]}".to_string()) -} - -pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result { - let mut replacements = Vec::new(); - for edit in request.edits { - let start = utf16_position_to_byte_offset(&request.text, edit.range.start)?; - let end = utf16_position_to_byte_offset(&request.text, edit.range.end)?; - if end < start { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Language server returned an invalid text range.", - ) - .with_details("invalidRange")); - } - replacements.push((start, end, edit.new_text)); - } - replacements.sort_by_key(|(start, _, _)| *start); - for pair in replacements.windows(2) { - if pair[0].1 > pair[1].0 { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Language server returned overlapping text edits.", - ) - .with_details("overlappingEdits")); - } - } - - let mut text = request.text; - for (start, end, replacement) in replacements.into_iter().rev() { - text.replace_range(start..end, &replacement); - } - Ok(TextResponse { text }) -} - -pub fn plain_snippet(request: PlainSnippetRequest) -> TextResponse { - TextResponse { - text: snippet_plain_text(&request.value), - } -} - -pub fn builtin_completions( - request: BuiltinRequest, -) -> Result { - validate_file_path(&request.file_path)?; - let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; - let prefix = identifier_prefix_at(&request.text, cursor); - let start_column = request.position.utf16_column - prefix.encode_utf16().count() as i64; - let replacement_range = LspRangeResponse { - start: LspPositionResponse { - line: request.position.line, - utf16_column: start_column.max(0), - }, - end: LspPositionResponse { - line: request.position.line, - utf16_column: request.position.utf16_column, - }, - }; - - let mut seen = BTreeMap::::new(); - for occurrence in identifier_occurrences(&request.text) { - if occurrence.value == prefix { - continue; - } - if !prefix.is_empty() && !occurrence.value.starts_with(&prefix) { - continue; - } - let kind = builtin_completion_kind(&request.text, occurrence.start); - seen.entry(occurrence.value).or_insert(kind); - } - - let items = seen - .into_iter() - .take(80) - .map(|(label, kind)| BuiltinCompletionItem { - insert_text: label.clone(), - label, - kind: Some(kind), - detail: Some("Current file symbol".to_string()), - text_edit: LspTextEditResponse { - range: replacement_range, - new_text: String::new(), - }, - }) - .map(|mut item| { - item.text_edit.new_text = item.insert_text.clone(); - item - }) - .collect(); - Ok(BuiltinCompletionResponse { items }) -} - -pub fn builtin_hover(request: BuiltinRequest) -> Result { - validate_file_path(&request.file_path)?; - let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; - let Some(identifier) = identifier_at(&request.text, cursor) else { - return Ok(BuiltinHoverResponse { hover: None }); - }; - Ok(BuiltinHoverResponse { - hover: Some(BuiltinHover { - contents: format!("`{}`", identifier.value), - is_markdown: true, - range: identifier.range, - }), - }) -} - -pub fn builtin_navigation( - request: BuiltinNavigationRequest, -) -> Result { - validate_file_path(&request.file_path)?; - let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; - let Some(identifier) = identifier_at(&request.text, cursor) else { - return Ok(BuiltinNavigationResponse { - locations: Vec::new(), - }); - }; - let mut occurrences: Vec<_> = identifier_occurrences(&request.text) - .into_iter() - .filter(|occurrence| occurrence.value == identifier.value) - .collect(); - - if request.method == "textDocument/definition" - || request.method == "textDocument/declaration" - || request.method == "textDocument/typeDefinition" - { - let declarations: Vec<_> = occurrences - .iter() - .filter(|occurrence| looks_like_declaration(&request.text, occurrence.start)) - .cloned() - .collect(); - if !declarations.is_empty() { - occurrences = declarations; - } - } else if request.method == "textDocument/implementation" { - occurrences.retain(|occurrence| occurrence.start != identifier.start); - } - - let locations = occurrences - .into_iter() - .take(200) - .map(|occurrence| BuiltinLocation { - file_path: request.file_path.clone(), - range: occurrence.range, - is_read_only: false, - display_path: None, - }) - .collect(); - Ok(BuiltinNavigationResponse { locations }) -} - -pub fn client_initialize(request: ClientInitializeRequest) -> Result { - validate_uri(&request.root_uri)?; - let mut state = request.state; - let id = allocate_request(&mut state, "initialize"); - let message = json_rpc_request( - &id, - "initialize", - json!({ - "processId": request.process_id, - "rootUri": request.root_uri, - "capabilities": { - "textDocument": { - "synchronization": {}, - "completion": { - "dynamicRegistration": true, - "completionItem": { - "snippetSupport": false, - "documentationFormat": ["markdown", "plaintext"] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": ["markdown", "plaintext"] - }, - "definition": { "dynamicRegistration": true }, - "declaration": { "dynamicRegistration": true }, - "typeDefinition": { "dynamicRegistration": true }, - "implementation": { "dynamicRegistration": true }, - "references": { "dynamicRegistration": true }, - "rename": { "dynamicRegistration": true }, - "formatting": { "dynamicRegistration": true }, - "codeAction": { - "dynamicRegistration": true, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": ["quickfix", "refactor", "source"] - } - } - }, - "publishDiagnostics": { - "relatedInformation": true - } - }, - "workspace": { - "configuration": true, - "workspaceEdit": { - "documentChanges": true - }, - "executeCommand": { "dynamicRegistration": true } - }, - "window": { - "workDoneProgress": true - } - }, - "initializationOptions": request.initialization_options - }), - )?; - Ok(client_response(state, vec![message], Vec::new())) -} - -pub fn client_open_document( - request: ClientOpenDocumentRequest, -) -> Result { - validate_uri(&request.uri)?; - let mut state = request.state; - let document = LspClientDocument { - uri: request.uri.clone(), - language_id: request.language_id, - version: 1, - text: request.text, - }; - let message = json_rpc_notification( - "textDocument/didOpen", - json!({ - "textDocument": { - "uri": document.uri, - "languageId": document.language_id, - "version": document.version, - "text": document.text - } - }), - )?; - state.open_documents.insert(request.uri, document); - Ok(client_response(state, vec![message], Vec::new())) -} - -pub fn client_change_document( - request: ClientChangeDocumentRequest, -) -> Result { - validate_uri(&request.uri)?; - let mut state = request.state; - let Some(document) = state.open_documents.get_mut(&request.uri) else { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Cannot change a document that is not open in the LSP client.", - )); - }; - document.version += 1; - document.text = request.text; - let message = json_rpc_notification( - "textDocument/didChange", - json!({ - "textDocument": { - "uri": document.uri, - "version": document.version - }, - "contentChanges": [{ - "text": document.text - }] - }), - )?; - Ok(client_response(state, vec![message], Vec::new())) -} - -pub fn client_close_document( - request: ClientCloseDocumentRequest, -) -> Result { - validate_uri(&request.uri)?; - let mut state = request.state; - let Some(document) = state.open_documents.remove(&request.uri) else { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Cannot close a document that is not open in the LSP client.", - )); - }; - let message = json_rpc_notification( - "textDocument/didClose", - json!({ - "textDocument": { - "uri": document.uri - } - }), - )?; - Ok(client_response(state, vec![message], Vec::new())) -} - -pub fn client_shutdown(request: ClientShutdownRequest) -> Result { - let mut state = request.state; - if state.shutdown_requested { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "LSP client shutdown has already been requested.", - )); - } - let id = allocate_request(&mut state, "shutdown"); - state.shutdown_requested = true; - let message = json_rpc_message_without_params(Some(&id), "shutdown")?; - Ok(client_response(state, vec![message], Vec::new())) -} - -pub fn client_feature_request( - request: ClientFeatureRequest, -) -> Result { - validate_uri(&request.uri)?; - validate_lsp_method(&request.method)?; - let params = feature_request_params(&request)?; - let uri = request.uri.clone(); - let method = request.method.clone(); - let mut state = request.state; - if !state.open_documents.contains_key(&uri) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Cannot request LSP features for a document that is not open.", - )); - } - let id = allocate_request(&mut state, &method); - let message = json_rpc_request(&id, &method, params)?; - Ok(client_response(state, vec![message], Vec::new())) -} - -pub fn client_apply_server_message( - request: ClientApplyServerMessageRequest, -) -> Result { - let mut state = request.state; - let message: Value = serde_json::from_str(&request.message).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP server JSON message") - .with_details(error.to_string()) - })?; - let mut responses = Vec::new(); - let mut events = Vec::new(); - - if let Some(method) = message.get("method").and_then(Value::as_str) { - let request_id = message.get("id"); - match method { - "textDocument/publishDiagnostics" => { - if let Some(params) = message.get("params") { - let uri = params - .get("uri") - .and_then(Value::as_str) - .unwrap_or_default(); - validate_uri(uri)?; - let diagnostics = parse_diagnostics(params.get("diagnostics")); - state - .diagnostics - .insert(uri.to_string(), diagnostics.clone()); - events.push(LspClientEvent { - kind: "diagnostics".to_string(), - request_id: None, - method: None, - uri: Some(uri.to_string()), - diagnostics: Some(diagnostics), - result: None, - error: None, - }); - } - } - "client/registerCapability" => { - apply_dynamic_registration(&mut state, &message); - if let Some(id) = request_id { - responses.push(json_rpc_result(id, Value::Null)?); - } - } - "client/unregisterCapability" => { - apply_dynamic_unregistration(&mut state, &message); - if let Some(id) = request_id { - responses.push(json_rpc_result(id, Value::Null)?); - } - } - "workspace/configuration" => { - if let Some(id) = request_id { - let item_count = message - .get("params") - .and_then(|params| params.get("items")) - .and_then(Value::as_array) - .map_or(0, Vec::len); - responses.push(json_rpc_result( - id, - Value::Array(vec![Value::Null; item_count]), - )?); - } - } - "workspace/workspaceFolders" | "window/workDoneProgress/create" => { - if let Some(id) = request_id { - responses.push(json_rpc_result(id, Value::Null)?); - } - } - _ => { - if let Some(id) = request_id { - responses.push(json_rpc_error(id, -32601, "Method not found")?); - } else { - events.push(LspClientEvent { - kind: "notification".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - diagnostics: None, - result: message.get("params").cloned(), - error: None, - }); - } - } - } - } else if let Some(id) = lsp_message_id(&message) { - let pending = state.pending_requests.remove(&id); - if pending.as_deref() == Some("initialize") { - if let Some(result) = message.get("result") { - state.server_capabilities = feature_names_from_capabilities( - result.get("capabilities").unwrap_or(&Value::Null), - ); - state.initialized = true; - responses.push(json_rpc_notification("initialized", json!({}))?); - } - } - if pending.as_deref() == Some("shutdown") { - state.initialized = false; - state.shutdown_requested = false; - state.server_capabilities.clear(); - state.open_documents.clear(); - state.diagnostics.clear(); - responses.push(json_rpc_message_without_params(None, "exit")?); - } - let result = lsp_feature_result_for_method(pending.as_deref(), message.get("result")); - events.push(LspClientEvent { - kind: if message.get("error").is_some() { - "error".to_string() - } else { - "response".to_string() - }, - request_id: Some(id), - method: pending, - uri: None, - diagnostics: None, - result, - error: message.get("error").map(|value| value.to_string()), - }); - } - - Ok(client_response(state, responses, events)) -} - -pub fn frame_message(request: FrameMessageRequest) -> Result { - if request.message.contains('\0') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "LSP message frame cannot contain NUL bytes.", - )); - } - Ok(FrameMessageResponse { - frame: format!( - "Content-Length: {}\r\n\r\n{}", - request.message.len(), - request.message - ), - }) -} - -pub fn parse_server_messages( - request: ParseServerMessagesRequest, -) -> Result { - let mut buffer = request.buffer; - buffer.extend(request.chunk); - let mut messages = Vec::new(); - - while let Some(header_end) = find_header_end(&buffer) { - let header = String::from_utf8_lossy(&buffer[..header_end]); - let Some(content_length) = content_length_from_header(&header) else { - buffer.drain(..header_end + 4); - continue; - }; - let body_start = header_end + 4; - let body_end = body_start + content_length; - if buffer.len() < body_end { - break; - } - let body = buffer[body_start..body_end].to_vec(); - buffer.drain(..body_end); - if let Ok(message) = String::from_utf8(body) { - messages.push(message); - } - } - - Ok(ParseServerMessagesResponse { buffer, messages }) -} - -pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { - let mut diagnostics = Vec::new(); - let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { - Ok(document) => document, - Err(message) => { - diagnostics.push(LspProviderConfigDiagnostic { - path: "builtin:lsp".to_string(), - message, - }); - LspProviderConfigDocument { - _schema: None, - version: 1, - providers: Vec::new(), - } - } - }; - - if let Some(root) = workspace_root { - let path = project_config_path(root); - if path.is_file() { - match std::fs::read_to_string(&path) { - Ok(raw) => match parse_document(&raw, &path.display().to_string()) { - Ok(project_document) => { - document = merge_documents(document, project_document); - } - Err(message) => diagnostics.push(LspProviderConfigDiagnostic { - path: path.display().to_string(), - message, - }), - }, - Err(error) => diagnostics.push(LspProviderConfigDiagnostic { - path: path.display().to_string(), - message: error.to_string(), - }), - } - } - } - - let mut providers = Vec::new(); - for patch in document.providers { - if patch.disabled { - continue; - } - providers.push(LspProviderDescriptor::from_patch(patch)); - } - LspProviderCatalog { - version: document.version, - providers, - diagnostics, - } -} - -fn parse_document(raw: &str, source: &str) -> Result { - serde_json::from_str(raw).map_err(|error| format!("{source}: {error}")) -} - -fn merge_documents( - mut base: LspProviderConfigDocument, - project: LspProviderConfigDocument, -) -> LspProviderConfigDocument { - base.version = project.version.max(base.version); - for patch in project.providers { - if let Some(existing) = base - .providers - .iter_mut() - .find(|provider| provider.id == patch.id) - { - existing.apply(patch); - } else { - base.providers.push(patch); - } - } - base -} - -fn project_config_path(root: &Path) -> PathBuf { - root.join(".lithe") - .join("lsp") - .join("language-providers.json") -} - -fn default_config_version() -> u32 { - 1 -} - -fn default_next_request_id() -> u64 { - 1 -} - -fn client_response( - state: LspClientState, - messages: Vec, - events: Vec, -) -> LspClientResponse { - LspClientResponse { - state, - messages, - events, - } -} - -fn allocate_request(state: &mut LspClientState, method: &str) -> String { - let id = state.next_request_id.to_string(); - state.next_request_id += 1; - state - .pending_requests - .insert(id.clone(), method.to_string()); - id -} - -fn json_rpc_request(id: &str, method: &str, params: Value) -> Result { - encode_json_rpc(json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - })) -} - -fn json_rpc_notification(method: &str, params: Value) -> Result { - encode_json_rpc(json!({ - "jsonrpc": "2.0", - "method": method, - "params": params - })) -} - -fn json_rpc_message_without_params(id: Option<&str>, method: &str) -> Result { - let mut message = json!({ - "jsonrpc": "2.0", - "method": method - }); - if let Some(id) = id { - message["id"] = Value::String(id.to_string()); - } - encode_json_rpc(message) -} - -fn json_rpc_result(id: &Value, result: Value) -> Result { - encode_json_rpc(json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - })) -} - -fn json_rpc_error(id: &Value, code: i64, message: &str) -> Result { - encode_json_rpc(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": code, - "message": message - } - })) -} - -fn encode_json_rpc(value: Value) -> Result { - serde_json::to_string(&value).map_err(|error| { - CoreError::new(ErrorCode::Unknown, "Could not encode LSP JSON-RPC message") - .with_details(error.to_string()) - }) -} - -fn find_header_end(buffer: &[u8]) -> Option { - buffer.windows(4).position(|window| window == b"\r\n\r\n") -} - -fn content_length_from_header(header: &str) -> Option { - header.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - if name.trim().eq_ignore_ascii_case("content-length") { - value.trim().parse().ok() - } else { - None - } - }) -} - -fn validate_uri(value: &str) -> Result<(), CoreError> { - if value.trim().is_empty() || value.contains('\0') { - Err(CoreError::new( - ErrorCode::InvalidRequest, - "LSP request requires a valid URI.", - )) - } else { - Ok(()) - } -} - -fn validate_lsp_method(method: &str) -> Result<(), CoreError> { - match method { - "textDocument/completion" - | "textDocument/hover" - | "textDocument/definition" - | "textDocument/declaration" - | "textDocument/typeDefinition" - | "textDocument/implementation" - | "textDocument/references" - | "textDocument/rename" - | "textDocument/formatting" - | "textDocument/codeAction" - | "completionItem/resolve" - | "codeAction/resolve" - | "workspace/executeCommand" => Ok(()), - _ => Err(CoreError::new( - ErrorCode::NotSupported, - "Unsupported LSP client request method.", - ) - .with_details(method.to_string())), - } -} - -fn feature_request_params(request: &ClientFeatureRequest) -> Result { - let text_document = json!({ "uri": request.uri }); - match request.method.as_str() { - "textDocument/completion" - | "textDocument/hover" - | "textDocument/definition" - | "textDocument/declaration" - | "textDocument/typeDefinition" - | "textDocument/implementation" => Ok(json!({ - "textDocument": text_document, - "position": lsp_position_json(required_position(request)?) - })), - "textDocument/references" => Ok(json!({ - "textDocument": text_document, - "position": lsp_position_json(required_position(request)?), - "context": { "includeDeclaration": true } - })), - "textDocument/rename" => Ok(json!({ - "textDocument": text_document, - "position": lsp_position_json(required_position(request)?), - "newName": request.new_name.clone().unwrap_or_default() - })), - "textDocument/formatting" => Ok(json!({ - "textDocument": text_document, - "options": { - "tabSize": 4, - "insertSpaces": true, - "trimTrailingWhitespace": true, - "insertFinalNewline": true, - "trimFinalNewlines": true - } - })), - "textDocument/codeAction" => Ok(json!({ - "textDocument": text_document, - "range": lsp_range_json(required_range(request)?), - "context": { - "diagnostics": request - .diagnostics - .iter() - .map(lsp_diagnostic_json) - .collect::>() - } - })), - "completionItem/resolve" => request - .completion_item - .as_ref() - .map(swift_completion_item_to_lsp) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This LSP request requires a completion item.", - ) - }), - "codeAction/resolve" => request - .code_action - .as_ref() - .map(swift_code_action_to_lsp) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This LSP request requires a code action.", - ) - }), - "workspace/executeCommand" => request - .command - .as_ref() - .and_then(swift_command_to_lsp) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This LSP request requires a command.", - ) - }), - _ => Ok(json!({ "textDocument": text_document })), - } -} - -fn required_position(request: &ClientFeatureRequest) -> Result { - request.position.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This LSP request requires a text document position.", - ) - }) -} - -fn required_range(request: &ClientFeatureRequest) -> Result { - request.range.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This LSP request requires a text document range.", - ) - }) -} - -fn lsp_position_json(position: LspPosition) -> Value { - json!({ - "line": position.line, - "character": position.utf16_column - }) -} - -fn lsp_range_json(range: LspRange) -> Value { - json!({ - "start": lsp_position_json(range.start), - "end": lsp_position_json(range.end) - }) -} - -fn lsp_diagnostic_json(diagnostic: &LspClientDiagnostic) -> Value { - json!({ - "range": { - "start": { - "line": diagnostic.range.start.line, - "character": diagnostic.range.start.utf16_column - }, - "end": { - "line": diagnostic.range.end.line, - "character": diagnostic.range.end.utf16_column - } - }, - "severity": diagnostic.severity, - "message": diagnostic.message, - "source": diagnostic.source, - "code": diagnostic.code - }) -} - -fn swift_completion_item_to_lsp(item: &Value) -> Value { - let mut object = serde_json::Map::new(); - copy_string_field(item, &mut object, "label"); - copy_string_field(item, &mut object, "detail"); - copy_string_field(item, &mut object, "documentation"); - copy_string_field(item, &mut object, "insertText"); - copy_string_field(item, &mut object, "sortText"); - copy_string_field(item, &mut object, "filterText"); - if let Some(kind) = item.get("kind").and_then(Value::as_i64) { - object.insert("kind".to_string(), json!(kind)); - } - if let Some(edit) = item.get("textEdit").and_then(swift_text_edit_to_lsp) { - object.insert("textEdit".to_string(), edit); - } - if let Some(edits) = item.get("additionalTextEdits").and_then(Value::as_array) { - object.insert( - "additionalTextEdits".to_string(), - json!(edits - .iter() - .filter_map(swift_text_edit_to_lsp) - .collect::>()), - ); - } - if let Some(data) = item.get("data") { - object.insert("data".to_string(), data.clone()); - } - Value::Object(object) -} - -fn swift_code_action_to_lsp(action: &Value) -> Value { - let mut object = serde_json::Map::new(); - copy_string_field(action, &mut object, "title"); - copy_string_field(action, &mut object, "kind"); - if let Some(is_preferred) = action.get("isPreferred").and_then(Value::as_bool) { - object.insert("isPreferred".to_string(), json!(is_preferred)); - } - if let Some(edit) = action.get("edit").and_then(swift_workspace_edit_to_lsp) { - object.insert("edit".to_string(), edit); - } - if let Some(command) = action.get("command").and_then(swift_command_to_lsp) { - object.insert("command".to_string(), command); - } - if let Some(data) = action.get("data") { - object.insert("data".to_string(), data.clone()); - } - Value::Object(object) -} - -fn swift_workspace_edit_to_lsp(value: &Value) -> Option { - let changes = value.get("changes")?.as_object()?; - let mut parsed_changes = serde_json::Map::new(); - for (path, edits) in changes { - let uri = if path.starts_with("file://") { - path.clone() - } else { - format!("file://{path}") - }; - parsed_changes.insert( - uri, - json!(edits - .as_array() - .map(|values| values - .iter() - .filter_map(swift_text_edit_to_lsp) - .collect::>()) - .unwrap_or_default()), - ); - } - Some(json!({ "changes": parsed_changes })) -} - -fn swift_command_to_lsp(value: &Value) -> Option { - Some(json!({ - "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), - "command": value.get("command").and_then(Value::as_str)?, - "arguments": value - .get("arguments") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - })) -} - -fn copy_string_field(source: &Value, target: &mut serde_json::Map, field: &str) { - if let Some(value) = source.get(field).and_then(Value::as_str) { - target.insert(field.to_string(), json!(value)); - } -} - -fn swift_text_edit_to_lsp(value: &Value) -> Option { - Some(json!({ - "range": swift_range_to_lsp(value.get("range")?)?, - "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() - })) -} - -fn swift_range_to_lsp(value: &Value) -> Option { - Some(json!({ - "start": swift_position_to_lsp(value.get("start")?)?, - "end": swift_position_to_lsp(value.get("end")?)? - })) -} - -fn swift_position_to_lsp(value: &Value) -> Option { - Some(json!({ - "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), - "character": value - .get("utf16Column") - .or_else(|| value.get("character")) - .and_then(Value::as_i64) - .unwrap_or(0) - })) -} - -fn lsp_message_id(message: &Value) -> Option { - message.get("id").and_then(|id| match id { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - }) -} - -fn parse_diagnostics(value: Option<&Value>) -> Vec { - value - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(|item| { - Some(LspClientDiagnostic { - range: parse_lsp_range(item.get("range")?)?, - severity: item.get("severity").and_then(Value::as_i64), - message: item - .get("message") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - source: item - .get("source") - .and_then(Value::as_str) - .map(str::to_string), - code: item.get("code").and_then(|code| match code { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - }), - }) - }) - .collect() - }) - .unwrap_or_default() -} - -fn parse_lsp_range(value: &Value) -> Option { - Some(LspRangeResponse { - start: parse_lsp_position(value.get("start")?)?, - end: parse_lsp_position(value.get("end")?)?, - }) -} - -fn parse_lsp_position(value: &Value) -> Option { - Some(LspPositionResponse { - line: value.get("line")?.as_i64()?, - utf16_column: value.get("character")?.as_i64()?, - }) -} - -fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) -> Option { - let result = result?; - match method { - Some("textDocument/completion") => Some(json!({ - "items": parse_completion_items(result) - })), - Some("completionItem/resolve") => parse_completion_item(result).map(|item| { - json!({ - "item": item - }) - }), - Some("textDocument/hover") => Some(json!({ - "hover": parse_hover(result) - })), - Some("textDocument/rename") => Some(json!({ - "changes": parse_workspace_edit(result) - })), - Some("textDocument/formatting") => Some(json!({ - "edits": result - .as_array() - .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) - .unwrap_or_default() - })), - Some("textDocument/codeAction") => Some(json!({ - "actions": parse_code_actions(result) - })), - Some("codeAction/resolve") => parse_code_action(result).map(|action| { - json!({ - "action": action - }) - }), - Some("workspace/executeCommand") => Some(json!({ "ok": true })), - Some("textDocument/definition") - | Some("textDocument/declaration") - | Some("textDocument/typeDefinition") - | Some("textDocument/implementation") - | Some("textDocument/references") => Some(json!({ - "locations": parse_locations(result) - })), - _ => Some(result.clone()), - } -} - -fn parse_completion_items(result: &Value) -> Vec { - let values = result - .as_array() - .or_else(|| result.get("items").and_then(Value::as_array)); - let Some(values) = values else { - return Vec::new(); - }; - values - .iter() - .filter_map(|item| parse_completion_item(item)) - .collect() -} - -fn parse_completion_item(item: &Value) -> Option { - let label = item.get("label").and_then(Value::as_str)?; - let insert_text = item - .get("insertText") - .and_then(Value::as_str) - .or_else(|| { - item.get("textEdit") - .and_then(|edit| edit.get("newText")) - .and_then(Value::as_str) - }) - .unwrap_or(label); - Some(json!({ - "label": label, - "insertText": insert_text, - "kind": item.get("kind").and_then(Value::as_i64), - "detail": item.get("detail").and_then(Value::as_str), - "documentation": completion_documentation(item.get("documentation")), - "sortText": item.get("sortText").and_then(Value::as_str), - "filterText": item.get("filterText").and_then(Value::as_str), - "textEdit": item.get("textEdit").and_then(parse_lsp_text_edit_value), - "additionalTextEdits": item - .get("additionalTextEdits") - .and_then(Value::as_array) - .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) - .unwrap_or_default(), - "data": item.get("data").cloned().unwrap_or(Value::Null) - })) -} - -fn completion_documentation(value: Option<&Value>) -> Option { - match value? { - Value::String(text) => Some(text.clone()), - Value::Object(object) => object - .get("value") - .and_then(Value::as_str) - .map(ToString::to_string), - _ => None, - } -} - -fn parse_hover(result: &Value) -> Option { - if result.is_null() { - return None; - } - let contents = hover_contents(result.get("contents").unwrap_or(result))?; - let range = result.get("range").and_then(parse_lsp_range_value); - Some(json!({ - "contents": contents.0, - "isMarkdown": contents.1, - "range": range - })) -} - -fn hover_contents(value: &Value) -> Option<(String, bool)> { - match value { - Value::String(text) => Some((text.clone(), false)), - Value::Object(object) => { - if let Some(value) = object.get("value").and_then(Value::as_str) { - let is_markdown = object - .get("kind") - .and_then(Value::as_str) - .map(|kind| kind == "markdown") - .unwrap_or(false); - Some((value.to_string(), is_markdown)) - } else if let Some(value) = object.get("language").and_then(Value::as_str) { - Some((value.to_string(), true)) - } else { - None - } - } - Value::Array(values) => { - let parts: Vec<_> = values - .iter() - .filter_map(hover_contents) - .map(|(text, _)| text) - .collect(); - if parts.is_empty() { - None - } else { - Some((parts.join("\n\n"), true)) - } - } - _ => None, - } -} - -fn parse_locations(result: &Value) -> Vec { - let values: Vec<&Value> = if let Some(array) = result.as_array() { - array.iter().collect() - } else if result.is_object() { - vec![result] - } else { - Vec::new() - }; - values - .into_iter() - .filter_map(|location| { - let uri = location - .get("uri") - .or_else(|| location.get("targetUri")) - .and_then(Value::as_str)?; - let range = location - .get("range") - .or_else(|| location.get("targetSelectionRange")) - .or_else(|| location.get("targetRange")) - .and_then(parse_lsp_range_value)?; - Some(json!({ - "filePath": file_path_from_uri(uri), - "range": range, - "isReadOnly": false, - "displayPath": Value::Null - })) - }) - .collect() -} - -fn parse_code_actions(result: &Value) -> Vec { - let Some(values) = result.as_array() else { - return Vec::new(); - }; - values.iter().filter_map(parse_code_action).collect() -} - -fn parse_code_action(action: &Value) -> Option { - let title = action.get("title").and_then(Value::as_str)?; - let command = if action.get("command").and_then(Value::as_str).is_some() { - parse_lsp_command(action) - } else { - action.get("command").and_then(parse_lsp_command) - }; - Some(json!({ - "title": title, - "kind": action.get("kind").and_then(Value::as_str), - "isPreferred": action - .get("isPreferred") - .and_then(Value::as_bool) - .unwrap_or(false), - "edit": action.get("edit").map(|edit| json!({ - "changes": parse_workspace_edit(edit) - })), - "command": command, - "data": action.get("data").cloned().unwrap_or(Value::Null) - })) -} - -fn parse_lsp_command(value: &Value) -> Option { - Some(json!({ - "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), - "command": value.get("command").and_then(Value::as_str)?, - "arguments": value - .get("arguments") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - })) -} - -fn parse_workspace_edit(result: &Value) -> serde_json::Map { - let mut changes = serde_json::Map::new(); - if let Some(entries) = result.get("changes").and_then(Value::as_object) { - for (uri, edits) in entries { - let parsed = edits - .as_array() - .map(|edits| { - edits - .iter() - .filter_map(parse_lsp_text_edit_value) - .collect::>() - }) - .unwrap_or_default(); - changes.insert(file_path_from_uri(uri), json!(parsed)); - } - } - if let Some(document_changes) = result.get("documentChanges").and_then(Value::as_array) { - for change in document_changes { - let Some(uri) = change - .get("textDocument") - .and_then(|document| document.get("uri")) - .and_then(Value::as_str) - else { - continue; - }; - let parsed = change - .get("edits") - .and_then(Value::as_array) - .map(|edits| { - edits - .iter() - .filter_map(parse_lsp_text_edit_value) - .collect::>() - }) - .unwrap_or_default(); - changes.insert(file_path_from_uri(uri), json!(parsed)); - } - } - changes -} - -fn parse_lsp_text_edit_value(value: &Value) -> Option { - Some(json!({ - "range": parse_lsp_range_value(value.get("range")?)?, - "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() - })) -} - -fn parse_lsp_range_value(value: &Value) -> Option { - Some(json!({ - "start": parse_lsp_position_value(value.get("start")?)?, - "end": parse_lsp_position_value(value.get("end")?)? - })) -} - -fn parse_lsp_position_value(value: &Value) -> Option { - Some(json!({ - "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), - "utf16Column": value - .get("character") - .or_else(|| value.get("utf16Column")) - .and_then(Value::as_i64) - .unwrap_or(0) - })) -} - -fn file_path_from_uri(uri: &str) -> String { - let path = uri.strip_prefix("file://").unwrap_or(uri); - let mut decoded = Vec::with_capacity(path.len()); - let bytes = path.as_bytes(); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' && index + 2 < bytes.len() { - if let (Some(high), Some(low)) = - (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) - { - decoded.push((high << 4) | low); - index += 3; - continue; - } - } - decoded.push(bytes[index]); - index += 1; - } - String::from_utf8(decoded).unwrap_or_else(|_| path.to_string()) -} - -fn hex_value(value: u8) -> Option { - match value { - b'0'..=b'9' => Some(value - b'0'), - b'a'..=b'f' => Some(value - b'a' + 10), - b'A'..=b'F' => Some(value - b'A' + 10), - _ => None, - } -} - -fn feature_names_from_capabilities(capabilities: &Value) -> Vec { - let mut values = Vec::new(); - add_capability( - &mut values, - capabilities, - "definitionProvider", - "definition", - ); - add_capability( - &mut values, - capabilities, - "declarationProvider", - "declaration", - ); - add_capability( - &mut values, - capabilities, - "typeDefinitionProvider", - "typeDefinition", - ); - add_capability( - &mut values, - capabilities, - "implementationProvider", - "implementation", - ); - add_capability( - &mut values, - capabilities, - "referencesProvider", - "references", - ); - add_capability(&mut values, capabilities, "hoverProvider", "hover"); - add_capability( - &mut values, - capabilities, - "completionProvider", - "completion", - ); - add_capability(&mut values, capabilities, "renameProvider", "rename"); - add_capability( - &mut values, - capabilities, - "documentFormattingProvider", - "formatting", - ); - add_capability( - &mut values, - capabilities, - "codeActionProvider", - "codeActions", - ); - add_capability( - &mut values, - capabilities, - "executeCommandProvider", - "executeCommand", - ); - if capabilities - .get("completionProvider") - .and_then(|value| value.get("resolveProvider")) - .and_then(Value::as_bool) - == Some(true) - { - insert_unique(&mut values, "completionResolve"); - } - if capabilities - .get("codeActionProvider") - .and_then(|value| value.get("resolveProvider")) - .and_then(Value::as_bool) - == Some(true) - { - insert_unique(&mut values, "codeActionResolve"); - } - values -} - -fn add_capability(values: &mut Vec, capabilities: &Value, key: &str, feature: &str) { - match capabilities.get(key) { - Some(Value::Bool(true)) => insert_unique(values, feature), - Some(Value::Object(_)) => insert_unique(values, feature), - _ => {} - } -} - -fn apply_dynamic_registration(state: &mut LspClientState, message: &Value) { - let Some(registrations) = message - .get("params") - .and_then(|params| params.get("registrations")) - .and_then(Value::as_array) - else { - return; - }; - for registration in registrations { - if let Some(feature) = registration - .get("method") - .and_then(Value::as_str) - .and_then(feature_name_for_method) - { - insert_unique(&mut state.server_capabilities, feature); - } - if registration - .get("registerOptions") - .and_then(|options| options.get("resolveProvider")) - .and_then(Value::as_bool) - == Some(true) - { - if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") - { - insert_unique(&mut state.server_capabilities, "completionResolve"); - } - if registration.get("method").and_then(Value::as_str) == Some("textDocument/codeAction") - { - insert_unique(&mut state.server_capabilities, "codeActionResolve"); - } - } - } -} - -fn apply_dynamic_unregistration(state: &mut LspClientState, message: &Value) { - let Some(unregistrations) = message - .get("params") - .and_then(|params| { - params - .get("unregistrations") - .or_else(|| params.get("unregisterations")) - }) - .and_then(Value::as_array) - else { - return; - }; - for unregistration in unregistrations { - if let Some(feature) = unregistration - .get("method") - .and_then(Value::as_str) - .and_then(feature_name_for_method) - { - state - .server_capabilities - .retain(|existing| existing != feature); - } - } -} - -fn feature_name_for_method(method: &str) -> Option<&'static str> { - match method { - "textDocument/definition" => Some("definition"), - "textDocument/declaration" => Some("declaration"), - "textDocument/typeDefinition" => Some("typeDefinition"), - "textDocument/implementation" => Some("implementation"), - "textDocument/references" => Some("references"), - "textDocument/hover" => Some("hover"), - "textDocument/completion" => Some("completion"), - "textDocument/rename" => Some("rename"), - "textDocument/formatting" => Some("formatting"), - "textDocument/codeAction" => Some("codeActions"), - "workspace/executeCommand" => Some("executeCommand"), - _ => None, - } -} - -fn insert_unique(values: &mut Vec, value: &str) { - if !values.iter().any(|existing| existing == value) { - values.push(value.to_string()); - } -} - -impl LspProviderPatch { - fn apply(&mut self, patch: LspProviderPatch) { - if patch.display_name.is_some() { - self.display_name = patch.display_name; - } - if patch.file_extensions.is_some() { - self.file_extensions = patch.file_extensions; - } - if patch.file_names.is_some() { - self.file_names = patch.file_names; - } - if patch.file_name_prefixes.is_some() { - self.file_name_prefixes = patch.file_name_prefixes; - } - if patch.capabilities.is_some() { - self.capabilities = patch.capabilities; - } - if patch.activation_policy.is_some() { - self.activation_policy = patch.activation_policy; - } - if patch.language_id.is_some() { - self.language_id = patch.language_id; - } - if patch.language_ids_by_extension.is_some() { - self.language_ids_by_extension = patch.language_ids_by_extension; - } - if patch.language_ids_by_file_name.is_some() { - self.language_ids_by_file_name = patch.language_ids_by_file_name; - } - if patch.language_server_launch.is_some() { - self.language_server_launch = patch.language_server_launch; - } - if patch.language_server_installation.is_some() { - self.language_server_installation = patch.language_server_installation; - } - self.disabled = patch.disabled; - } -} - -impl LspProviderDescriptor { - fn from_patch(patch: LspProviderPatch) -> Self { - let id = normalized_id(&patch.id); - let display_name = patch - .display_name - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| id.clone()); - let capabilities = patch.capabilities.unwrap_or_else(|| { - vec![ - LspProviderCapability::LanguageServer, - LspProviderCapability::Formatting, - ] - }); - Self { - id: id.clone(), - display_name, - file_extensions: normalized_values(patch.file_extensions.unwrap_or_default(), true), - file_names: normalized_values(patch.file_names.unwrap_or_default(), false), - file_name_prefixes: normalized_values( - patch.file_name_prefixes.unwrap_or_default(), - false, - ), - capabilities, - activation_policy: patch.activation_policy.unwrap_or_default(), - language_id: patch.language_id.filter(|value| !value.trim().is_empty()), - language_ids_by_extension: normalized_map( - patch.language_ids_by_extension.unwrap_or_default(), - true, - ), - language_ids_by_file_name: normalized_map( - patch.language_ids_by_file_name.unwrap_or_default(), - false, - ), - language_server_launch: patch.language_server_launch, - language_server_installation: patch.language_server_installation, - } - } -} - -fn normalized_id(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - -fn normalized_values(values: Vec, trim_dot: bool) -> Vec { - let mut result = Vec::new(); - for value in values { - let normalized = normalized_key(&value, trim_dot); - if !normalized.is_empty() && !result.contains(&normalized) { - result.push(normalized); - } - } - result -} - -fn normalized_map(values: BTreeMap, trim_dot: bool) -> BTreeMap { - values - .into_iter() - .filter_map(|(key, value)| { - let key = normalized_key(&key, trim_dot); - if key.is_empty() || value.trim().is_empty() { - None - } else { - Some((key, value)) - } - }) - .collect() -} - -fn normalized_key(value: &str, trim_dot: bool) -> String { - let mut value = value.trim().to_ascii_lowercase(); - if trim_dot { - value = value.trim_start_matches('.').to_string(); - } - value -} - -fn validate_file_path(value: &str) -> Result<(), CoreError> { - if value.trim().is_empty() { - Err(CoreError::new( - ErrorCode::InvalidRequest, - "LSP builtin request requires a file path.", - )) - } else { - Ok(()) - } -} - -fn utf16_position_to_byte_offset(text: &str, position: LspPosition) -> Result { - if position.line < 0 || position.utf16_column < 0 { - return Err(invalid_range_error()); - } - let line = usize::try_from(position.line).map_err(|_| invalid_range_error())?; - let column = usize::try_from(position.utf16_column).map_err(|_| invalid_range_error())?; - let Some((start, contents_end)) = line_bounds(text, line) else { - return Err(invalid_range_error()); - }; - Ok(byte_offset_for_utf16_column( - text, - start, - contents_end, - column, - )) -} - -fn invalid_range_error() -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, - "Language server returned an invalid text range.", - ) - .with_details("invalidRange") -} - -fn line_bounds(text: &str, target_line: usize) -> Option<(usize, usize)> { - let bytes = text.as_bytes(); - let mut line = 0; - let mut start = 0; - for (index, byte) in bytes.iter().enumerate() { - if *byte == b'\n' { - if line == target_line { - let contents_end = if index > start && bytes[index - 1] == b'\r' { - index - 1 - } else { - index - }; - return Some((start, contents_end)); - } - line += 1; - start = index + 1; - } - } - if line == target_line { - Some((start, text.len())) - } else { - None - } -} - -fn byte_offset_for_utf16_column( - text: &str, - start: usize, - contents_end: usize, - column: usize, -) -> usize { - let mut units = 0; - for (relative, character) in text[start..contents_end].char_indices() { - let next_units = units + character.len_utf16(); - if next_units > column { - return start + relative; - } - units = next_units; - if units == column { - return start + relative + character.len_utf8(); - } - } - contents_end -} - -fn byte_offset_to_lsp_position(text: &str, offset: usize) -> LspPositionResponse { - let offset = offset.min(text.len()); - let mut line = 0_i64; - let mut column = 0_i64; - for (index, character) in text.char_indices() { - if index >= offset { - break; - } - if character == '\n' { - line += 1; - column = 0; - } else { - column += character.len_utf16() as i64; - } - } - LspPositionResponse { - line, - utf16_column: column, - } -} - -fn range_for_offsets(text: &str, start: usize, end: usize) -> LspRangeResponse { - LspRangeResponse { - start: byte_offset_to_lsp_position(text, start), - end: byte_offset_to_lsp_position(text, end), - } -} - -fn identifier_occurrences(text: &str) -> Vec { - let mut values = Vec::new(); - let mut current_start: Option = None; - for (index, character) in text.char_indices() { - if is_identifier_character(character) { - if current_start.is_none() { - current_start = Some(index); - } - } else if let Some(start) = current_start.take() { - push_identifier(text, start, index, &mut values); - } - } - if let Some(start) = current_start { - push_identifier(text, start, text.len(), &mut values); - } - values -} - -fn push_identifier(text: &str, start: usize, end: usize, values: &mut Vec) { - let value = &text[start..end]; - if value.chars().next().is_some_and(is_identifier_start) - && !is_language_keyword(value) - && value.len() <= 120 - { - values.push(IdentifierOccurrence { - value: value.to_string(), - start, - end, - range: range_for_offsets(text, start, end), - }); - } -} - -fn identifier_at(text: &str, cursor: usize) -> Option { - identifier_occurrences(text) - .into_iter() - .find(|occurrence| occurrence.start <= cursor && cursor <= occurrence.end) -} - -fn identifier_prefix_at(text: &str, cursor: usize) -> String { - let mut start = cursor.min(text.len()); - while start > 0 { - let Some((previous_index, previous)) = text[..start].char_indices().next_back() else { - break; - }; - if !is_identifier_character(previous) { - break; - } - start = previous_index; - } - text[start..cursor.min(text.len())].to_string() -} - -fn is_identifier_start(character: char) -> bool { - character == '_' || character.is_alphabetic() -} - -fn is_identifier_character(character: char) -> bool { - character == '_' || character.is_alphanumeric() -} - -fn is_language_keyword(value: &str) -> bool { - matches!( - value, - "as" | "async" - | "await" - | "break" - | "case" - | "catch" - | "class" - | "const" - | "continue" - | "def" - | "default" - | "defer" - | "do" - | "else" - | "enum" - | "export" - | "extends" - | "false" - | "final" - | "fn" - | "for" - | "func" - | "function" - | "if" - | "impl" - | "import" - | "in" - | "interface" - | "let" - | "match" - | "mod" - | "mut" - | "nil" - | "null" - | "package" - | "private" - | "protected" - | "public" - | "return" - | "self" - | "static" - | "struct" - | "switch" - | "this" - | "throw" - | "throws" - | "trait" - | "true" - | "try" - | "type" - | "var" - | "while" - ) -} - -fn builtin_completion_kind(text: &str, start: usize) -> i32 { - if looks_like_declaration_with_keywords( - text, - start, - &["class", "struct", "enum", "interface", "trait"], - ) { - 7 - } else if looks_like_declaration_with_keywords(text, start, &["func", "function", "def", "fn"]) - { - 3 - } else { - 6 - } -} - -fn looks_like_declaration(text: &str, start: usize) -> bool { - looks_like_declaration_with_keywords( - text, - start, - &[ - "class", - "struct", - "enum", - "interface", - "trait", - "func", - "function", - "def", - "fn", - "let", - "var", - "const", - "type", - ], - ) -} - -fn looks_like_declaration_with_keywords(text: &str, start: usize, keywords: &[&str]) -> bool { - let line_start = text[..start].rfind('\n').map_or(0, |index| index + 1); - let prefix = &text[line_start..start]; - let tokens: Vec<&str> = prefix - .split(|character: char| !is_identifier_character(character)) - .filter(|token| !token.is_empty()) - .collect(); - tokens - .last() - .is_some_and(|token| keywords.iter().any(|keyword| keyword == token)) -} - -fn snippet_plain_text(value: &str) -> String { - let mut output = String::new(); - let mut chars = value.chars().peekable(); - while let Some(character) = chars.next() { - if character != '$' { - output.push(character); - continue; - } - match chars.peek().copied() { - Some('{') => { - chars.next(); - if !consume_digits(&mut chars) { - output.push_str("${"); - continue; - } - match chars.peek().copied() { - Some(':') => { - chars.next(); - output.push_str(&consume_until_placeholder_end(&mut chars)); - } - Some('}') => { - chars.next(); - } - _ => output.push('$'), - } - } - Some(next) if next.is_ascii_digit() => { - consume_digits(&mut chars); - } - _ => output.push('$'), - } - } - output -} - -fn consume_digits(chars: &mut std::iter::Peekable) -> bool -where - I: Iterator, -{ - let mut consumed = false; - while chars - .peek() - .is_some_and(|character| character.is_ascii_digit()) - { - chars.next(); - consumed = true; - } - consumed -} - -fn consume_until_placeholder_end(chars: &mut std::iter::Peekable) -> String -where - I: Iterator, -{ - let mut value = String::new(); - for character in chars.by_ref() { - if character == '}' { - break; - } - value.push(character); - } - value -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::Value; - use std::fs; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn temporary_root(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be valid") - .as_nanos(); - std::env::temp_dir().join(format!("lithe-lsp-{label}-{}-{nonce}", std::process::id())) - } - - #[test] - fn builtin_catalog_describes_market_lsp_providers() { - let catalog = provider_catalog(None); - let ids: Vec<_> = catalog - .providers - .iter() - .map(|provider| provider.id.as_str()) - .collect(); - assert!(ids.starts_with(&["java", "go", "python", "node", "rust"])); - assert!(ids.contains(&"swift")); - assert!(ids.contains(&"clangd")); - assert!(ids.contains(&"dockerfile")); - assert!(ids.contains(&"graphql")); - let clangd = catalog - .providers - .iter() - .find(|provider| provider.id == "clangd") - .expect("clangd provider should exist"); - assert_eq!( - clangd.language_ids_by_extension.get("m"), - Some(&"objective-c".to_string()) - ); - let swift = catalog - .providers - .iter() - .find(|provider| provider.id == "swift") - .expect("swift provider should exist"); - let swift_launch = swift - .language_server_launch - .as_ref() - .expect("swift launch descriptor should exist"); - assert_eq!( - swift_launch.executable_names, - vec!["sourcekit-lsp".to_string()] - ); - let go = catalog - .providers - .iter() - .find(|provider| provider.id == "go") - .expect("go provider should exist"); - let go_installation = go - .language_server_installation - .as_ref() - .expect("go installation descriptor should exist"); - assert_eq!(go_installation.homebrew_formula.as_deref(), Some("gopls")); - assert_eq!( - go_installation.official_download_url.as_deref(), - Some("https://go.dev/gopls/") - ); - } - - #[test] - fn project_config_extends_and_overrides_builtin_catalog() { - let root = temporary_root("project-config"); - fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); - fs::write( - root.join(".lithe/lsp/language-providers.json"), - r#"{ - "version": 1, - "providers": [ - { - "id": "roc", - "displayName": "Roc", - "fileExtensions": ["roc"], - "capabilities": ["languageServer", "formatting"], - "activationPolicy": "onDemand", - "languageId": "roc" - }, - { - "id": "swift", - "fileExtensions": ["swift", "swiftinterface"], - "languageServerLaunch": { - "executableNames": ["custom-sourcekit-lsp"], - "arguments": ["--stdio"], - "environment": { - "SOURCEKIT_TOOLCHAIN": "custom" - }, - "initializationOptions": { - "indexing": true - } - }, - "languageServerInstallation": { - "homebrewFormula": "custom-sourcekit-lsp", - "officialDownloadURL": "https://example.com/sourcekit-lsp" - } - }, - { - "id": "perl", - "disabled": true - } - ] - }"#, - ) - .unwrap(); - - let catalog = provider_catalog(Some(&root)); - assert!(catalog - .providers - .iter() - .any(|provider| provider.id == "roc")); - let swift = catalog - .providers - .iter() - .find(|provider| provider.id == "swift") - .expect("swift provider should still exist"); - assert!(swift - .file_extensions - .contains(&"swiftinterface".to_string())); - let swift_launch = swift - .language_server_launch - .as_ref() - .expect("swift launch descriptor should be overridden"); - assert_eq!( - swift_launch.executable_names, - vec!["custom-sourcekit-lsp".to_string()] - ); - assert_eq!(swift_launch.arguments, vec!["--stdio".to_string()]); - assert_eq!( - swift_launch.environment.get("SOURCEKIT_TOOLCHAIN"), - Some(&"custom".to_string()) - ); - assert_eq!( - swift_launch.initialization_options, - Some(json!({ "indexing": true })) - ); - let swift_installation = swift - .language_server_installation - .as_ref() - .expect("swift installation descriptor should be overridden"); - assert_eq!( - swift_installation.homebrew_formula.as_deref(), - Some("custom-sourcekit-lsp") - ); - assert!(!catalog - .providers - .iter() - .any(|provider| provider.id == "perl")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn ffi_json_is_a_standalone_catalog_document() { - let raw = provider_catalog_json(None); - let value: Value = serde_json::from_str(&raw).expect("catalog should be JSON"); - assert_eq!(value["version"], 2); - assert!(value["providers"].as_array().unwrap().len() > 10); - assert!(value.get("ok").is_none()); - assert!(value.get("command").is_none()); - } - - #[test] - fn project_catalog_reports_unknown_configuration_fields() { - let root = temporary_root("project-config-unknown-field"); - fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); - fs::write( - root.join(".lithe/lsp/language-providers.json"), - r#"{ - "version": 2, - "providers": [{ "id": "go", "languageServerLanch": {} }] - }"#, - ) - .unwrap(); - - let catalog = provider_catalog(Some(&root)); - assert_eq!(catalog.diagnostics.len(), 1); - assert!(catalog.diagnostics[0].message.contains("unknown field")); - assert!(catalog.providers.iter().any(|provider| provider.id == "go")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn text_edits_use_lsp_utf16_positions() { - let response = apply_text_edits(ApplyTextEditsRequest { - text: "one 😀\ntwo three\n".to_string(), - edits: vec![ - LspTextEdit { - range: LspRange { - start: LspPosition { - line: 0, - utf16_column: 4, - }, - end: LspPosition { - line: 0, - utf16_column: 6, - }, - }, - new_text: "rocket".to_string(), - }, - LspTextEdit { - range: LspRange { - start: LspPosition { - line: 1, - utf16_column: 4, - }, - end: LspPosition { - line: 1, - utf16_column: 9, - }, - }, - new_text: "four".to_string(), - }, - ], - }) - .unwrap(); - - assert_eq!(response.text, "one rocket\ntwo four\n"); - } - - #[test] - fn text_edits_reject_invalid_and_overlapping_ranges() { - let invalid = apply_text_edits(ApplyTextEditsRequest { - text: "one line".to_string(), - edits: vec![LspTextEdit { - range: LspRange { - start: LspPosition { - line: 9, - utf16_column: 0, - }, - end: LspPosition { - line: 9, - utf16_column: 1, - }, - }, - new_text: "x".to_string(), - }], - }) - .unwrap_err(); - assert_eq!(invalid.details.as_deref(), Some("invalidRange")); - - let overlapping = apply_text_edits(ApplyTextEditsRequest { - text: "one line".to_string(), - edits: vec![ - LspTextEdit { - range: LspRange { - start: LspPosition { - line: 0, - utf16_column: 0, - }, - end: LspPosition { - line: 0, - utf16_column: 4, - }, - }, - new_text: "a".to_string(), - }, - LspTextEdit { - range: LspRange { - start: LspPosition { - line: 0, - utf16_column: 2, - }, - end: LspPosition { - line: 0, - utf16_column: 6, - }, - }, - new_text: "b".to_string(), - }, - ], - }) - .unwrap_err(); - assert_eq!(overlapping.details.as_deref(), Some("overlappingEdits")); - } - - #[test] - fn snippet_plain_text_removes_tab_stops_and_keeps_defaults() { - assert_eq!(snippet_plain_text("print(${1:value})$0"), "print(value)"); - assert_eq!(snippet_plain_text("${1:let} ${2:name} = $3"), "let name = "); - } - - #[test] - fn builtin_completion_returns_current_file_identifiers_for_prefix() { - let response = builtin_completions(BuiltinRequest { - file_path: "/tmp/main.swift".to_string(), - text: "struct RocketShip {}\nlet rocketSpeed = Roc\n".to_string(), - position: LspPosition { - line: 1, - utf16_column: 19, - }, - }) - .unwrap(); - - assert!(response.items.iter().any(|item| item.label == "RocketShip")); - let item = response - .items - .iter() - .find(|item| item.label == "RocketShip") - .unwrap(); - assert_eq!(item.text_edit.range.start.utf16_column, 18); - assert_eq!(item.text_edit.new_text, "RocketShip"); - } - - #[test] - fn builtin_hover_returns_current_identifier_range() { - let response = builtin_hover(BuiltinRequest { - file_path: "/tmp/main.rs".to_string(), - text: "fn launch() {}\n".to_string(), - position: LspPosition { - line: 0, - utf16_column: 4, - }, - }) - .unwrap(); - - let hover = response.hover.unwrap(); - assert_eq!(hover.contents, "`launch`"); - assert_eq!(hover.range.start.utf16_column, 3); - assert_eq!(hover.range.end.utf16_column, 9); - } - - #[test] - fn builtin_navigation_prefers_declarations_and_finds_references() { - let text = "let service = 1\nprint(service)\n"; - let definitions = builtin_navigation(BuiltinNavigationRequest { - file_path: "/tmp/main.swift".to_string(), - text: text.to_string(), - position: LspPosition { - line: 1, - utf16_column: 8, - }, - method: "textDocument/definition".to_string(), - }) - .unwrap(); - assert_eq!(definitions.locations.len(), 1); - assert_eq!(definitions.locations[0].range.start.line, 0); - assert_eq!(definitions.locations[0].range.start.utf16_column, 4); - - let references = builtin_navigation(BuiltinNavigationRequest { - file_path: "/tmp/main.swift".to_string(), - text: text.to_string(), - position: LspPosition { - line: 1, - utf16_column: 8, - }, - method: "textDocument/references".to_string(), - }) - .unwrap(); - assert_eq!(references.locations.len(), 2); - } - - #[test] - fn file_uri_paths_decode_spaces_and_utf8_characters() { - assert_eq!( - file_path_from_uri("file:///tmp/go%20project/%E4%B8%AD%E6%96%87/main.go"), - "/tmp/go project/中文/main.go" - ); - } - - #[test] - fn client_core_initializes_and_applies_server_capabilities() { - let initialized = client_initialize(ClientInitializeRequest { - state: LspClientState::default(), - root_uri: "file:///tmp/project".to_string(), - process_id: Some(42), - initialization_options: Some(json!({ - "ui.semanticTokens": true - })), - }) - .unwrap(); - assert_eq!( - initialized.state.pending_requests.get("1").unwrap(), - "initialize" - ); - let initialize_message: Value = - serde_json::from_str(&initialized.messages[0]).expect("initialize JSON"); - assert_eq!(initialize_message["method"], "initialize"); - assert_eq!( - initialize_message["params"]["rootUri"], - "file:///tmp/project" - ); - let client_capabilities = &initialize_message["params"]["capabilities"]; - assert_eq!(client_capabilities["workspace"]["configuration"], true); - assert_eq!( - client_capabilities["textDocument"]["completion"]["completionItem"]["snippetSupport"], - false - ); - assert_eq!( - initialize_message["params"]["initializationOptions"]["ui.semanticTokens"], - true - ); - assert!(client_capabilities["workspace"].get("applyEdit").is_none()); - assert!(client_capabilities["textDocument"]["synchronization"] - .get("didSave") - .is_none()); - assert_eq!(client_capabilities["window"]["workDoneProgress"], true); - - let applied = client_apply_server_message(ClientApplyServerMessageRequest { - state: initialized.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "1", - "result": { - "capabilities": { - "definitionProvider": true, - "hoverProvider": true, - "completionProvider": { "resolveProvider": true }, - "codeActionProvider": { "resolveProvider": true } - } - } - }"# - .to_string(), - }) - .unwrap(); - - assert!(applied.state.initialized); - assert!(applied.state.pending_requests.is_empty()); - assert!(applied - .state - .server_capabilities - .contains(&"definition".to_string())); - assert!(applied - .state - .server_capabilities - .contains(&"completionResolve".to_string())); - assert_eq!(applied.messages.len(), 1); - let initialized_notification: Value = - serde_json::from_str(&applied.messages[0]).expect("initialized JSON"); - assert_eq!(initialized_notification["method"], "initialized"); - } - - #[test] - fn client_core_tracks_documents_and_feature_requests() { - let opened = client_open_document(ClientOpenDocumentRequest { - state: LspClientState::default(), - uri: "file:///tmp/project/main.rs".to_string(), - language_id: "rust".to_string(), - text: "fn main() {}\n".to_string(), - }) - .unwrap(); - assert_eq!( - opened - .state - .open_documents - .get("file:///tmp/project/main.rs") - .unwrap() - .version, - 1 - ); - let did_open: Value = serde_json::from_str(&opened.messages[0]).unwrap(); - assert_eq!(did_open["method"], "textDocument/didOpen"); - - let changed = client_change_document(ClientChangeDocumentRequest { - state: opened.state, - uri: "file:///tmp/project/main.rs".to_string(), - text: "fn main() { launch(); }\n".to_string(), - }) - .unwrap(); - assert_eq!( - changed - .state - .open_documents - .get("file:///tmp/project/main.rs") - .unwrap() - .version, - 2 - ); - let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); - assert_eq!(did_change["method"], "textDocument/didChange"); - - let requested = client_feature_request(ClientFeatureRequest { - state: changed.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "textDocument/definition".to_string(), - position: Some(LspPosition { - line: 0, - utf16_column: 12, - }), - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_item: None, - code_action: None, - command: None, - }) - .unwrap(); - assert_eq!( - requested.state.pending_requests.get("1").unwrap(), - "textDocument/definition" - ); - let request_message: Value = serde_json::from_str(&requested.messages[0]).unwrap(); - assert_eq!(request_message["method"], "textDocument/definition"); - assert_eq!(request_message["params"]["position"]["character"], 12); - } - - #[test] - fn client_core_closes_open_documents() { - let uri = "file:///tmp/project/main.go"; - let opened = client_open_document(ClientOpenDocumentRequest { - state: LspClientState::default(), - uri: uri.to_string(), - language_id: "go".to_string(), - text: "package main\n".to_string(), - }) - .unwrap(); - - let closed = client_close_document(ClientCloseDocumentRequest { - state: opened.state, - uri: uri.to_string(), - }) - .unwrap(); - - assert!(!closed.state.open_documents.contains_key(uri)); - assert_eq!(closed.messages.len(), 1); - let did_close: Value = serde_json::from_str(&closed.messages[0]).unwrap(); - assert_eq!( - did_close, - json!({ - "jsonrpc": "2.0", - "method": "textDocument/didClose", - "params": { - "textDocument": { - "uri": uri - } - } - }) - ); - - let error = client_close_document(ClientCloseDocumentRequest { - state: closed.state, - uri: uri.to_string(), - }) - .unwrap_err(); - assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); - } - - #[test] - fn client_core_waits_for_shutdown_response_before_exiting() { - let mut state = LspClientState { - initialized: true, - ..LspClientState::default() - }; - state.server_capabilities.push("completion".to_string()); - state.open_documents.insert( - "file:///tmp/project/main.go".to_string(), - LspClientDocument { - uri: "file:///tmp/project/main.go".to_string(), - language_id: "go".to_string(), - version: 1, - text: "package main\n".to_string(), - }, - ); - - let shutting_down = client_shutdown(ClientShutdownRequest { state }).unwrap(); - assert!(shutting_down.state.shutdown_requested); - assert_eq!( - shutting_down.state.pending_requests.get("1"), - Some(&"shutdown".to_string()) - ); - let shutdown: Value = serde_json::from_str(&shutting_down.messages[0]).unwrap(); - assert_eq!( - shutdown, - json!({ - "jsonrpc": "2.0", - "id": "1", - "method": "shutdown" - }) - ); - - let exited = client_apply_server_message(ClientApplyServerMessageRequest { - state: shutting_down.state, - message: json!({ - "jsonrpc": "2.0", - "id": "1", - "result": null - }) - .to_string(), - }) - .unwrap(); - - assert!(!exited.state.initialized); - assert!(!exited.state.shutdown_requested); - assert!(exited.state.pending_requests.is_empty()); - assert!(exited.state.server_capabilities.is_empty()); - assert!(exited.state.open_documents.is_empty()); - assert_eq!(exited.messages.len(), 1); - let exit: Value = serde_json::from_str(&exited.messages[0]).unwrap(); - assert_eq!( - exit, - json!({ - "jsonrpc": "2.0", - "method": "exit" - }) - ); - assert_eq!(exited.events.len(), 1); - assert_eq!(exited.events[0].method.as_deref(), Some("shutdown")); - } - - #[test] - fn client_core_rejects_duplicate_shutdown_requests() { - let shutting_down = client_shutdown(ClientShutdownRequest { - state: LspClientState::default(), - }) - .unwrap(); - - let error = client_shutdown(ClientShutdownRequest { - state: shutting_down.state, - }) - .unwrap_err(); - assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); - } - - #[test] - fn frame_message_uses_lsp_content_length_bytes() { - let message = - r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"你好"}}"#; - let framed = frame_message(FrameMessageRequest { - message: message.to_string(), - }) - .unwrap(); - assert!(framed - .frame - .starts_with(&format!("Content-Length: {}\r\n\r\n", message.len()))); - assert!(framed.frame.ends_with(message)); - } - - #[test] - fn parse_server_messages_returns_complete_messages_and_remaining_buffer() { - let first = r#"{"jsonrpc":"2.0","id":1,"result":null}"#; - let second = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"ok"}}"#; - let first_frame = frame_message(FrameMessageRequest { - message: first.to_string(), - }) - .unwrap() - .frame; - let second_frame = frame_message(FrameMessageRequest { - message: second.to_string(), - }) - .unwrap() - .frame; - let split_at = first_frame.len() - 3; - let partial = parse_server_messages(ParseServerMessagesRequest { - buffer: Vec::new(), - chunk: first_frame.as_bytes()[..split_at].to_vec(), - }) - .unwrap(); - assert!(partial.messages.is_empty()); - assert_eq!(partial.buffer, first_frame.as_bytes()[..split_at]); - - let mut next_chunk = first_frame.as_bytes()[split_at..].to_vec(); - next_chunk.extend(second_frame.as_bytes()); - let parsed = parse_server_messages(ParseServerMessagesRequest { - buffer: partial.buffer, - chunk: next_chunk, - }) - .unwrap(); - assert_eq!(parsed.messages, vec![first.to_string(), second.to_string()]); - assert!(parsed.buffer.is_empty()); - } - - #[test] - fn client_core_shapes_feature_responses_for_swift_models() { - let opened = client_open_document(ClientOpenDocumentRequest { - state: LspClientState::default(), - uri: "file:///tmp/project/main.rs".to_string(), - language_id: "rust".to_string(), - text: "fn main() { la }\n".to_string(), - }) - .unwrap(); - let requested = client_feature_request(ClientFeatureRequest { - state: opened.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "textDocument/completion".to_string(), - position: Some(LspPosition { - line: 0, - utf16_column: 14, - }), - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_item: None, - code_action: None, - command: None, - }) - .unwrap(); - let completed = client_apply_server_message(ClientApplyServerMessageRequest { - state: requested.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "1", - "result": { - "items": [{ - "label": "launch", - "kind": 3, - "detail": "fn()", - "textEdit": { - "range": { - "start": { "line": 0, "character": 12 }, - "end": { "line": 0, "character": 14 } - }, - "newText": "launch" - } - }] - } - }"# - .to_string(), - }) - .unwrap(); - - let result = completed.events[0].result.as_ref().unwrap(); - assert_eq!(result["items"][0]["label"], "launch"); - assert_eq!( - result["items"][0]["textEdit"]["range"]["start"]["utf16Column"], - 12 - ); - - let rename = client_feature_request(ClientFeatureRequest { - state: completed.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "textDocument/rename".to_string(), - position: Some(LspPosition { - line: 0, - utf16_column: 12, - }), - new_name: Some("start".to_string()), - range: None, - diagnostics: Vec::new(), - completion_item: None, - code_action: None, - command: None, - }) - .unwrap(); - let renamed = client_apply_server_message(ClientApplyServerMessageRequest { - state: rename.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "2", - "result": { - "changes": { - "file:///tmp/project/main.rs": [{ - "range": { - "start": { "line": 0, "character": 12 }, - "end": { "line": 0, "character": 18 } - }, - "newText": "start" - }] - } - } - }"# - .to_string(), - }) - .unwrap(); - let rename_result = renamed.events[0].result.as_ref().unwrap(); - assert_eq!( - rename_result["changes"]["/tmp/project/main.rs"][0]["newText"], - "start" - ); - - let formatting = client_feature_request(ClientFeatureRequest { - state: renamed.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "textDocument/formatting".to_string(), - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_item: None, - code_action: None, - command: None, - }) - .unwrap(); - let formatted = client_apply_server_message(ClientApplyServerMessageRequest { - state: formatting.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "3", - "result": [{ - "range": { - "start": { "line": 0, "character": 2 }, - "end": { "line": 0, "character": 2 } - }, - "newText": " " - }] - }"# - .to_string(), - }) - .unwrap(); - let format_result = formatted.events[0].result.as_ref().unwrap(); - assert_eq!( - format_result["edits"][0]["range"]["start"]["utf16Column"], - 2 - ); - - let code_actions = client_feature_request(ClientFeatureRequest { - state: formatted.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "textDocument/codeAction".to_string(), - position: None, - new_name: None, - range: Some(LspRange { - start: LspPosition { - line: 0, - utf16_column: 0, - }, - end: LspPosition { - line: 0, - utf16_column: 0, - }, - }), - diagnostics: vec![LspClientDiagnostic { - range: LspRangeResponse { - start: LspPositionResponse { - line: 0, - utf16_column: 12, - }, - end: LspPositionResponse { - line: 0, - utf16_column: 18, - }, - }, - severity: Some(2), - message: "rename suggestion".to_string(), - source: Some("rust-analyzer".to_string()), - code: None, - }], - completion_item: None, - code_action: None, - command: None, - }) - .unwrap(); - let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); - assert_eq!(code_action_request["method"], "textDocument/codeAction"); - assert_eq!( - code_action_request["params"]["context"]["diagnostics"][0]["range"]["start"] - ["character"], - 12 - ); - let code_actioned = client_apply_server_message(ClientApplyServerMessageRequest { - state: code_actions.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "4", - "result": [{ - "title": "Apply rename", - "kind": "quickfix", - "isPreferred": true, - "edit": { - "changes": { - "file:///tmp/project/main.rs": [{ - "range": { - "start": { "line": 0, "character": 12 }, - "end": { "line": 0, "character": 18 } - }, - "newText": "start" - }] - } - }, - "command": { - "title": "Apply", - "command": "rust-analyzer.applySourceChange", - "arguments": [{ "label": "rename" }] - }, - "data": { "id": "action-1" } - }] - }"# - .to_string(), - }) - .unwrap(); - let action_result = code_actioned.events[0].result.as_ref().unwrap(); - assert_eq!(action_result["actions"][0]["title"], "Apply rename"); - assert_eq!( - action_result["actions"][0]["edit"]["changes"]["/tmp/project/main.rs"][0]["newText"], - "start" - ); - assert_eq!( - action_result["actions"][0]["command"]["command"], - "rust-analyzer.applySourceChange" - ); - - let code_action_resolve = client_feature_request(ClientFeatureRequest { - state: code_actioned.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "codeAction/resolve".to_string(), - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_item: None, - code_action: Some(json!({ - "title": "Apply rename", - "kind": "quickfix", - "isPreferred": true, - "data": { "id": "action-1" } - })), - command: None, - }) - .unwrap(); - let code_action_resolve_request: Value = - serde_json::from_str(&code_action_resolve.messages[0]).unwrap(); - assert_eq!(code_action_resolve_request["method"], "codeAction/resolve"); - assert_eq!( - code_action_resolve_request["params"]["title"], - "Apply rename" - ); - let code_action_resolved = client_apply_server_message(ClientApplyServerMessageRequest { - state: code_action_resolve.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "5", - "result": { - "title": "Apply rename", - "kind": "quickfix", - "edit": { - "changes": { - "file:///tmp/project/main.rs": [{ - "range": { - "start": { "line": 0, "character": 12 }, - "end": { "line": 0, "character": 18 } - }, - "newText": "start" - }] - } - }, - "data": { "id": "action-1" } - } - }"# - .to_string(), - }) - .unwrap(); - let code_action_resolve_result = code_action_resolved.events[0].result.as_ref().unwrap(); - assert_eq!( - code_action_resolve_result["action"]["edit"]["changes"]["/tmp/project/main.rs"][0] - ["newText"], - "start" - ); - - let completion_resolve = client_feature_request(ClientFeatureRequest { - state: code_action_resolved.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "completionItem/resolve".to_string(), - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_item: Some(json!({ - "label": "launch", - "insertText": "launch", - "kind": 3, - "textEdit": { - "range": { - "start": { "line": 0, "utf16Column": 12 }, - "end": { "line": 0, "utf16Column": 14 } - }, - "newText": "launch" - }, - "data": { "id": "completion-1" } - })), - code_action: None, - command: None, - }) - .unwrap(); - let completion_resolve_request: Value = - serde_json::from_str(&completion_resolve.messages[0]).unwrap(); - assert_eq!( - completion_resolve_request["method"], - "completionItem/resolve" - ); - assert_eq!( - completion_resolve_request["params"]["textEdit"]["range"]["start"]["character"], - 12 - ); - let completion_resolved = client_apply_server_message(ClientApplyServerMessageRequest { - state: completion_resolve.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "6", - "result": { - "label": "launch", - "kind": 3, - "detail": "fn launch()", - "documentation": { "kind": "markdown", "value": "Launches the app." }, - "insertText": "launch", - "data": { "id": "completion-1" } - } - }"# - .to_string(), - }) - .unwrap(); - let completion_resolve_result = completion_resolved.events[0].result.as_ref().unwrap(); - assert_eq!( - completion_resolve_result["item"]["documentation"], - "Launches the app." - ); - - let execute_command = client_feature_request(ClientFeatureRequest { - state: completion_resolved.state, - uri: "file:///tmp/project/main.rs".to_string(), - method: "workspace/executeCommand".to_string(), - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_item: None, - code_action: None, - command: Some(json!({ - "title": "Apply", - "command": "rust-analyzer.applySourceChange", - "arguments": [{ "label": "rename" }] - })), - }) - .unwrap(); - let execute_request: Value = serde_json::from_str(&execute_command.messages[0]).unwrap(); - assert_eq!(execute_request["method"], "workspace/executeCommand"); - assert_eq!( - execute_request["params"]["command"], - "rust-analyzer.applySourceChange" - ); - let executed = client_apply_server_message(ClientApplyServerMessageRequest { - state: execute_command.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "7", - "result": null - }"# - .to_string(), - }) - .unwrap(); - assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); - } - - #[test] - fn client_core_applies_diagnostics_and_dynamic_registrations() { - let state = LspClientState::default(); - let diagnostics = client_apply_server_message(ClientApplyServerMessageRequest { - state, - message: r#"{ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { - "uri": "file:///tmp/project/main.py", - "diagnostics": [{ - "range": { - "start": { "line": 2, "character": 4 }, - "end": { "line": 2, "character": 9 } - }, - "severity": 1, - "source": "pyright", - "code": "reportGeneralTypeIssues", - "message": "Example diagnostic" - }] - } - }"# - .to_string(), - }) - .unwrap(); - let stored = diagnostics - .state - .diagnostics - .get("file:///tmp/project/main.py") - .unwrap(); - assert_eq!(stored[0].message, "Example diagnostic"); - assert_eq!(stored[0].range.start.utf16_column, 4); - assert_eq!(diagnostics.events[0].kind, "diagnostics"); - - let registered = client_apply_server_message(ClientApplyServerMessageRequest { - state: diagnostics.state, - message: r#"{ - "jsonrpc": "2.0", - "id": 77, - "method": "client/registerCapability", - "params": { - "registrations": [{ - "id": "formatting", - "method": "textDocument/formatting", - "registerOptions": {} - }] - } - }"# - .to_string(), - }) - .unwrap(); - assert!(registered - .state - .server_capabilities - .contains(&"formatting".to_string())); - let response: Value = serde_json::from_str(®istered.messages[0]).unwrap(); - assert_eq!( - response, - json!({ "jsonrpc": "2.0", "id": 77, "result": null }) - ); - - let unregistered = client_apply_server_message(ClientApplyServerMessageRequest { - state: registered.state, - message: r#"{ - "jsonrpc": "2.0", - "id": "unregister-1", - "method": "client/unregisterCapability", - "params": { - "unregisterations": [{ - "id": "formatting", - "method": "textDocument/formatting" - }] - } - }"# - .to_string(), - }) - .unwrap(); - assert!(!unregistered - .state - .server_capabilities - .contains(&"formatting".to_string())); - let response: Value = serde_json::from_str(&unregistered.messages[0]).unwrap(); - assert_eq!( - response, - json!({ "jsonrpc": "2.0", "id": "unregister-1", "result": null }) - ); - } - - #[test] - fn client_core_answers_workspace_configuration_requests_by_item() { - let response = client_apply_server_message(ClientApplyServerMessageRequest { - state: LspClientState::default(), - message: r#"{ - "jsonrpc": "2.0", - "id": "configuration-1", - "method": "workspace/configuration", - "params": { - "items": [ - { "section": "gopls" }, - { "scopeUri": "file:///tmp/project", "section": "gopls.ui" } - ] - } - }"# - .to_string(), - }) - .unwrap(); - - assert!(response.events.is_empty()); - assert_eq!(response.messages.len(), 1); - let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); - assert_eq!( - message, - json!({ - "jsonrpc": "2.0", - "id": "configuration-1", - "result": [null, null] - }) - ); - } - - #[test] - fn client_core_answers_workspace_folder_and_progress_requests() { - for (method, id) in [ - ("workspace/workspaceFolders", json!(42)), - ("window/workDoneProgress/create", json!("progress-1")), - ] { - let response = client_apply_server_message(ClientApplyServerMessageRequest { - state: LspClientState::default(), - message: json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": {} - }) - .to_string(), - }) - .unwrap(); - - assert!(response.events.is_empty()); - assert_eq!(response.messages.len(), 1); - let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); - assert_eq!( - message, - json!({ "jsonrpc": "2.0", "id": id, "result": null }) - ); - } - } - - #[test] - fn client_core_rejects_unknown_server_requests_with_method_not_found() { - let response = client_apply_server_message(ClientApplyServerMessageRequest { - state: LspClientState::default(), - message: r#"{ - "jsonrpc": "2.0", - "id": 91, - "method": "experimental/notSupported", - "params": { "value": true } - }"# - .to_string(), - }) - .unwrap(); - - assert!(response.events.is_empty()); - assert_eq!(response.messages.len(), 1); - let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); - assert_eq!( - message, - json!({ - "jsonrpc": "2.0", - "id": 91, - "error": { - "code": -32601, - "message": "Method not found" - } - }) - ); - } -} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs new file mode 100644 index 00000000..d08d5ea3 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -0,0 +1,1047 @@ +use super::types::*; +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::{json, Value}; + +pub fn client_initialize(request: ClientInitializeRequest) -> Result { + validate_uri(&request.root_uri)?; + let mut state = request.state; + let id = allocate_request(&mut state, "initialize"); + let message = json_rpc_request( + &id, + "initialize", + json!({ + "processId": request.process_id, + "rootUri": request.root_uri, + "capabilities": { + "textDocument": { + "synchronization": {}, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": false, + "documentationFormat": ["markdown", "plaintext"] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": ["markdown", "plaintext"] + }, + "definition": { "dynamicRegistration": true }, + "declaration": { "dynamicRegistration": true }, + "typeDefinition": { "dynamicRegistration": true }, + "implementation": { "dynamicRegistration": true }, + "references": { "dynamicRegistration": true }, + "rename": { "dynamicRegistration": true }, + "formatting": { "dynamicRegistration": true }, + "codeAction": { + "dynamicRegistration": true, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": ["quickfix", "refactor", "source"] + } + } + }, + "publishDiagnostics": { + "relatedInformation": true + } + }, + "workspace": { + "configuration": true, + "workspaceEdit": { + "documentChanges": true + }, + "executeCommand": { "dynamicRegistration": true } + }, + "window": { + "workDoneProgress": true + } + }, + "initializationOptions": request.initialization_options + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_open_document( + request: ClientOpenDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let document = LspClientDocument { + uri: request.uri.clone(), + language_id: request.language_id, + version: 1, + text: request.text, + }; + let message = json_rpc_notification( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": document.uri, + "languageId": document.language_id, + "version": document.version, + "text": document.text + } + }), + )?; + state.open_documents.insert(request.uri, document); + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_change_document( + request: ClientChangeDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.get_mut(&request.uri) else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot change a document that is not open in the LSP client.", + )); + }; + document.version += 1; + document.text = request.text; + let message = json_rpc_notification( + "textDocument/didChange", + json!({ + "textDocument": { + "uri": document.uri, + "version": document.version + }, + "contentChanges": [{ + "text": document.text + }] + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_close_document( + request: ClientCloseDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.remove(&request.uri) else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot close a document that is not open in the LSP client.", + )); + }; + let message = json_rpc_notification( + "textDocument/didClose", + json!({ + "textDocument": { + "uri": document.uri + } + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_shutdown(request: ClientShutdownRequest) -> Result { + let mut state = request.state; + if state.shutdown_requested { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP client shutdown has already been requested.", + )); + } + let id = allocate_request(&mut state, "shutdown"); + state.shutdown_requested = true; + let message = json_rpc_message_without_params(Some(&id), "shutdown")?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub(crate) fn client_feature_request_canonical( + request: ClientFeatureRequest, +) -> Result { + validate_uri(&request.uri)?; + validate_lsp_method(&request.method)?; + let params = feature_request_params(&request)?; + let uri = request.uri.clone(); + let method = request.method.clone(); + let mut state = request.state; + if !state.open_documents.contains_key(&uri) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot request LSP features for a document that is not open.", + )); + } + let id = allocate_request(&mut state, &method); + let message = json_rpc_request(&id, &method, params)?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_apply_server_message( + request: ClientApplyServerMessageRequest, +) -> Result { + let mut state = request.state; + let message: Value = serde_json::from_str(&request.message).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP server JSON message") + .with_details(error.to_string()) + })?; + let mut responses = Vec::new(); + let mut events = Vec::new(); + + if let Some(method) = message.get("method").and_then(Value::as_str) { + let request_id = message.get("id"); + match method { + "textDocument/publishDiagnostics" => { + if let Some(params) = message.get("params") { + let uri = params + .get("uri") + .and_then(Value::as_str) + .unwrap_or_default(); + validate_uri(uri)?; + let diagnostics = parse_diagnostics(params.get("diagnostics")); + state + .diagnostics + .insert(uri.to_string(), diagnostics.clone()); + events.push(LspClientEvent { + kind: "diagnostics".to_string(), + request_id: None, + method: None, + uri: Some(uri.to_string()), + diagnostics: Some(diagnostics), + result: None, + error: None, + }); + } + } + "client/registerCapability" => { + apply_dynamic_registration(&mut state, &message); + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + "client/unregisterCapability" => { + apply_dynamic_unregistration(&mut state, &message); + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + "workspace/configuration" => { + if let Some(id) = request_id { + let item_count = message + .get("params") + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + responses.push(json_rpc_result( + id, + Value::Array(vec![Value::Null; item_count]), + )?); + } + } + "workspace/workspaceFolders" | "window/workDoneProgress/create" => { + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + _ => { + if let Some(id) = request_id { + responses.push(json_rpc_error(id, -32601, "Method not found")?); + } else { + events.push(LspClientEvent { + kind: "notification".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + diagnostics: None, + result: message.get("params").cloned(), + error: None, + }); + } + } + } + } else if let Some(id) = lsp_message_id(&message) { + let pending = state.pending_requests.remove(&id); + if pending.as_deref() == Some("initialize") { + if let Some(result) = message.get("result") { + state.server_capabilities = feature_names_from_capabilities( + result.get("capabilities").unwrap_or(&Value::Null), + ); + state.initialized = true; + responses.push(json_rpc_notification("initialized", json!({}))?); + } + } + if pending.as_deref() == Some("shutdown") { + state.initialized = false; + state.shutdown_requested = false; + state.server_capabilities.clear(); + state.open_documents.clear(); + state.diagnostics.clear(); + responses.push(json_rpc_message_without_params(None, "exit")?); + } + let result = lsp_feature_result_for_method(pending.as_deref(), message.get("result")); + events.push(LspClientEvent { + kind: if message.get("error").is_some() { + "error".to_string() + } else { + "response".to_string() + }, + request_id: Some(id), + method: pending, + uri: None, + diagnostics: None, + result, + error: message.get("error").map(|value| value.to_string()), + }); + } + + Ok(client_response(state, responses, events)) +} + +fn client_response( + state: LspClientState, + messages: Vec, + events: Vec, +) -> LspClientResponse { + LspClientResponse { + state, + messages, + events, + } +} + +fn allocate_request(state: &mut LspClientState, method: &str) -> String { + let id = state.next_request_id.to_string(); + state.next_request_id += 1; + state + .pending_requests + .insert(id.clone(), method.to_string()); + id +} + +fn json_rpc_request(id: &str, method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) +} + +fn json_rpc_notification(method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + })) +} + +fn json_rpc_message_without_params(id: Option<&str>, method: &str) -> Result { + let mut message = json!({ + "jsonrpc": "2.0", + "method": method + }); + if let Some(id) = id { + message["id"] = Value::String(id.to_string()); + } + encode_json_rpc(message) +} + +fn json_rpc_result(id: &Value, result: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + })) +} + +fn json_rpc_error(id: &Value, code: i64, message: &str) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + })) +} + +fn encode_json_rpc(value: Value) -> Result { + serde_json::to_string(&value).map_err(|error| { + CoreError::new(ErrorCode::Unknown, "Could not encode LSP JSON-RPC message") + .with_details(error.to_string()) + }) +} + +fn validate_uri(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP request requires a valid URI.", + )) + } else { + Ok(()) + } +} + +fn validate_lsp_method(method: &str) -> Result<(), CoreError> { + match method { + "textDocument/completion" + | "textDocument/hover" + | "textDocument/definition" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation" + | "textDocument/references" + | "textDocument/rename" + | "textDocument/formatting" + | "textDocument/codeAction" + | "completionItem/resolve" + | "codeAction/resolve" + | "workspace/executeCommand" => Ok(()), + _ => Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported LSP client request method.", + ) + .with_details(method.to_string())), + } +} + +fn feature_request_params(request: &ClientFeatureRequest) -> Result { + let text_document = json!({ "uri": request.uri }); + match request.method.as_str() { + "textDocument/completion" + | "textDocument/hover" + | "textDocument/definition" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?) + })), + "textDocument/references" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?), + "context": { "includeDeclaration": true } + })), + "textDocument/rename" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?), + "newName": request.new_name.clone().unwrap_or_default() + })), + "textDocument/formatting" => Ok(json!({ + "textDocument": text_document, + "options": { + "tabSize": 4, + "insertSpaces": true, + "trimTrailingWhitespace": true, + "insertFinalNewline": true, + "trimFinalNewlines": true + } + })), + "textDocument/codeAction" => Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?), + "context": { + "diagnostics": request + .diagnostics + .iter() + .map(lsp_diagnostic_json) + .collect::>() + } + })), + "completionItem/resolve" => request.completion_item.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a completion item.", + ) + }), + "codeAction/resolve" => request.code_action.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a code action.", + ) + }), + "workspace/executeCommand" => request.command.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a command.", + ) + }), + _ => Ok(json!({ "textDocument": text_document })), + } +} + +fn required_position(request: &ClientFeatureRequest) -> Result { + request.position.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a text document position.", + ) + }) +} + +fn required_range(request: &ClientFeatureRequest) -> Result { + request.range.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a text document range.", + ) + }) +} + +fn lsp_position_json(position: LspPosition) -> Value { + json!({ + "line": position.line, + "character": position.utf16_column + }) +} + +fn lsp_range_json(range: LspRange) -> Value { + json!({ + "start": lsp_position_json(range.start), + "end": lsp_position_json(range.end) + }) +} + +fn lsp_diagnostic_json(diagnostic: &LspClientDiagnostic) -> Value { + json!({ + "range": { + "start": { + "line": diagnostic.range.start.line, + "character": diagnostic.range.start.utf16_column + }, + "end": { + "line": diagnostic.range.end.line, + "character": diagnostic.range.end.utf16_column + } + }, + "severity": diagnostic.severity, + "message": diagnostic.message, + "source": diagnostic.source, + "code": diagnostic.code + }) +} + +fn lsp_message_id(message: &Value) -> Option { + message.get("id").and_then(|id| match id { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + +fn parse_diagnostics(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(LspClientDiagnostic { + range: parse_lsp_range(item.get("range")?)?, + severity: item.get("severity").and_then(Value::as_i64), + message: item + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + source: item + .get("source") + .and_then(Value::as_str) + .map(str::to_string), + code: item.get("code").and_then(|code| match code { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_lsp_range(value: &Value) -> Option { + Some(LspRangeResponse { + start: parse_lsp_position(value.get("start")?)?, + end: parse_lsp_position(value.get("end")?)?, + }) +} + +fn parse_lsp_position(value: &Value) -> Option { + Some(LspPositionResponse { + line: value.get("line")?.as_i64()?, + utf16_column: value.get("character")?.as_i64()?, + }) +} + +fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) -> Option { + let result = result?; + match method { + Some("textDocument/completion") => Some(json!({ + "items": parse_completion_items(result) + })), + Some("completionItem/resolve") => parse_completion_item(result).map(|item| { + json!({ + "item": item + }) + }), + Some("textDocument/hover") => Some(json!({ + "hover": parse_hover(result) + })), + Some("textDocument/rename") => Some(json!({ + "changes": parse_workspace_edit(result) + })), + Some("textDocument/formatting") => Some(json!({ + "edits": result + .as_array() + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default() + })), + Some("textDocument/codeAction") => Some(json!({ + "actions": parse_code_actions(result) + })), + Some("codeAction/resolve") => parse_code_action(result).map(|action| { + json!({ + "action": action + }) + }), + Some("workspace/executeCommand") => Some(json!({ "ok": true })), + Some("textDocument/definition") + | Some("textDocument/declaration") + | Some("textDocument/typeDefinition") + | Some("textDocument/implementation") + | Some("textDocument/references") => Some(json!({ + "locations": parse_locations(result) + })), + _ => Some(result.clone()), + } +} + +fn parse_completion_items(result: &Value) -> Vec { + let values = result + .as_array() + .or_else(|| result.get("items").and_then(Value::as_array)); + let Some(values) = values else { + return Vec::new(); + }; + values + .iter() + .filter_map(|item| parse_completion_item(item)) + .collect() +} + +fn parse_completion_item(item: &Value) -> Option { + let label = item.get("label").and_then(Value::as_str)?; + let insert_text = item + .get("insertText") + .and_then(Value::as_str) + .or_else(|| { + item.get("textEdit") + .and_then(|edit| edit.get("newText")) + .and_then(Value::as_str) + }) + .unwrap_or(label); + Some(json!({ + "label": label, + "insertText": insert_text, + "kind": item.get("kind").and_then(Value::as_i64), + "detail": item.get("detail").and_then(Value::as_str), + "documentation": completion_documentation(item.get("documentation")), + "sortText": item.get("sortText").and_then(Value::as_str), + "filterText": item.get("filterText").and_then(Value::as_str), + "textEdit": item.get("textEdit").and_then(parse_lsp_text_edit_value), + "additionalTextEdits": item + .get("additionalTextEdits") + .and_then(Value::as_array) + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default(), + "data": item.get("data").cloned().unwrap_or(Value::Null) + })) +} + +fn completion_documentation(value: Option<&Value>) -> Option { + match value? { + Value::String(text) => Some(text.clone()), + Value::Object(object) => object + .get("value") + .and_then(Value::as_str) + .map(ToString::to_string), + _ => None, + } +} + +fn parse_hover(result: &Value) -> Option { + if result.is_null() { + return None; + } + let contents = hover_contents(result.get("contents").unwrap_or(result))?; + let range = result.get("range").and_then(parse_lsp_range_value); + Some(json!({ + "contents": contents.0, + "isMarkdown": contents.1, + "range": range + })) +} + +fn hover_contents(value: &Value) -> Option<(String, bool)> { + match value { + Value::String(text) => Some((text.clone(), false)), + Value::Object(object) => { + if let Some(value) = object.get("value").and_then(Value::as_str) { + let is_markdown = object + .get("kind") + .and_then(Value::as_str) + .map(|kind| kind == "markdown") + .unwrap_or(false); + Some((value.to_string(), is_markdown)) + } else if let Some(value) = object.get("language").and_then(Value::as_str) { + Some((value.to_string(), true)) + } else { + None + } + } + Value::Array(values) => { + let parts: Vec<_> = values + .iter() + .filter_map(hover_contents) + .map(|(text, _)| text) + .collect(); + if parts.is_empty() { + None + } else { + Some((parts.join("\n\n"), true)) + } + } + _ => None, + } +} + +fn parse_locations(result: &Value) -> Vec { + let values: Vec<&Value> = if let Some(array) = result.as_array() { + array.iter().collect() + } else if result.is_object() { + vec![result] + } else { + Vec::new() + }; + values + .into_iter() + .filter_map(|location| { + let uri = location + .get("uri") + .or_else(|| location.get("targetUri")) + .and_then(Value::as_str)?; + let range = location + .get("range") + .or_else(|| location.get("targetSelectionRange")) + .or_else(|| location.get("targetRange")) + .and_then(parse_lsp_range_value)?; + Some(json!({ + "filePath": file_path_from_uri(uri), + "range": range, + "isReadOnly": false, + "displayPath": Value::Null + })) + }) + .collect() +} + +fn parse_code_actions(result: &Value) -> Vec { + let Some(values) = result.as_array() else { + return Vec::new(); + }; + values.iter().filter_map(parse_code_action).collect() +} + +fn parse_code_action(action: &Value) -> Option { + let title = action.get("title").and_then(Value::as_str)?; + let command = if action.get("command").and_then(Value::as_str).is_some() { + parse_lsp_command(action) + } else { + action.get("command").and_then(parse_lsp_command) + }; + Some(json!({ + "title": title, + "kind": action.get("kind").and_then(Value::as_str), + "isPreferred": action + .get("isPreferred") + .and_then(Value::as_bool) + .unwrap_or(false), + "edit": action.get("edit").map(|edit| json!({ + "changes": parse_workspace_edit(edit) + })), + "command": command, + "data": action.get("data").cloned().unwrap_or(Value::Null) + })) +} + +fn parse_lsp_command(value: &Value) -> Option { + Some(json!({ + "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), + "command": value.get("command").and_then(Value::as_str)?, + "arguments": value + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + })) +} + +fn parse_workspace_edit(result: &Value) -> serde_json::Map { + let mut changes = serde_json::Map::new(); + if let Some(entries) = result.get("changes").and_then(Value::as_object) { + for (uri, edits) in entries { + let parsed = edits + .as_array() + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .unwrap_or_default(); + changes.insert(file_path_from_uri(uri), json!(parsed)); + } + } + if let Some(document_changes) = result.get("documentChanges").and_then(Value::as_array) { + for change in document_changes { + let Some(uri) = change + .get("textDocument") + .and_then(|document| document.get("uri")) + .and_then(Value::as_str) + else { + continue; + }; + let parsed = change + .get("edits") + .and_then(Value::as_array) + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .unwrap_or_default(); + changes.insert(file_path_from_uri(uri), json!(parsed)); + } + } + changes +} + +fn parse_lsp_text_edit_value(value: &Value) -> Option { + Some(json!({ + "range": parse_lsp_range_value(value.get("range")?)?, + "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() + })) +} + +fn parse_lsp_range_value(value: &Value) -> Option { + Some(json!({ + "start": parse_lsp_position_value(value.get("start")?)?, + "end": parse_lsp_position_value(value.get("end")?)? + })) +} + +fn parse_lsp_position_value(value: &Value) -> Option { + Some(json!({ + "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), + "utf16Column": value + .get("character") + .or_else(|| value.get("utf16Column")) + .and_then(Value::as_i64) + .unwrap_or(0) + })) +} + +pub(crate) fn file_path_from_uri(uri: &str) -> String { + let path = uri.strip_prefix("file://").unwrap_or(uri); + let mut decoded = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8(decoded).unwrap_or_else(|_| path.to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn feature_names_from_capabilities(capabilities: &Value) -> Vec { + let mut values = Vec::new(); + add_capability( + &mut values, + capabilities, + "definitionProvider", + "definition", + ); + add_capability( + &mut values, + capabilities, + "declarationProvider", + "declaration", + ); + add_capability( + &mut values, + capabilities, + "typeDefinitionProvider", + "typeDefinition", + ); + add_capability( + &mut values, + capabilities, + "implementationProvider", + "implementation", + ); + add_capability( + &mut values, + capabilities, + "referencesProvider", + "references", + ); + add_capability(&mut values, capabilities, "hoverProvider", "hover"); + add_capability( + &mut values, + capabilities, + "completionProvider", + "completion", + ); + add_capability(&mut values, capabilities, "renameProvider", "rename"); + add_capability( + &mut values, + capabilities, + "documentFormattingProvider", + "formatting", + ); + add_capability( + &mut values, + capabilities, + "codeActionProvider", + "codeActions", + ); + add_capability( + &mut values, + capabilities, + "executeCommandProvider", + "executeCommand", + ); + if capabilities + .get("completionProvider") + .and_then(|value| value.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + insert_unique(&mut values, "completionResolve"); + } + if capabilities + .get("codeActionProvider") + .and_then(|value| value.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + insert_unique(&mut values, "codeActionResolve"); + } + values +} + +fn add_capability(values: &mut Vec, capabilities: &Value, key: &str, feature: &str) { + match capabilities.get(key) { + Some(Value::Bool(true)) => insert_unique(values, feature), + Some(Value::Object(_)) => insert_unique(values, feature), + _ => {} + } +} + +fn apply_dynamic_registration(state: &mut LspClientState, message: &Value) { + let Some(registrations) = message + .get("params") + .and_then(|params| params.get("registrations")) + .and_then(Value::as_array) + else { + return; + }; + for registration in registrations { + if let Some(feature) = registration + .get("method") + .and_then(Value::as_str) + .and_then(feature_name_for_method) + { + insert_unique(&mut state.server_capabilities, feature); + } + if registration + .get("registerOptions") + .and_then(|options| options.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") + { + insert_unique(&mut state.server_capabilities, "completionResolve"); + } + if registration.get("method").and_then(Value::as_str) == Some("textDocument/codeAction") + { + insert_unique(&mut state.server_capabilities, "codeActionResolve"); + } + } + } +} + +fn apply_dynamic_unregistration(state: &mut LspClientState, message: &Value) { + let Some(unregistrations) = message + .get("params") + .and_then(|params| { + params + .get("unregistrations") + .or_else(|| params.get("unregisterations")) + }) + .and_then(Value::as_array) + else { + return; + }; + for unregistration in unregistrations { + if let Some(feature) = unregistration + .get("method") + .and_then(Value::as_str) + .and_then(feature_name_for_method) + { + state + .server_capabilities + .retain(|existing| existing != feature); + } + } +} + +fn feature_name_for_method(method: &str) -> Option<&'static str> { + match method { + "textDocument/definition" => Some("definition"), + "textDocument/declaration" => Some("declaration"), + "textDocument/typeDefinition" => Some("typeDefinition"), + "textDocument/implementation" => Some("implementation"), + "textDocument/references" => Some("references"), + "textDocument/hover" => Some("hover"), + "textDocument/completion" => Some("completion"), + "textDocument/rename" => Some("rename"), + "textDocument/formatting" => Some("formatting"), + "textDocument/codeAction" => Some("codeActions"), + "workspace/executeCommand" => Some("executeCommand"), + _ => None, + } +} + +fn insert_unique(values: &mut Vec, value: &str) { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } +} diff --git a/rust/lithe-core/src/lsp_host.rs b/rust/lithe-core/src/lsp/interface/host.rs similarity index 89% rename from rust/lithe-core/src/lsp_host.rs rename to rust/lithe-core/src/lsp/interface/host.rs index 08fe6b7d..28194e07 100644 --- a/rust/lithe-core/src/lsp_host.rs +++ b/rust/lithe-core/src/lsp/interface/host.rs @@ -1,10 +1,12 @@ -use crate::error::{CoreError, ErrorCode}; -use crate::lsp::{ - self, ClientApplyServerMessageRequest, ClientChangeDocumentRequest, ClientCloseDocumentRequest, +use super::{ + client_apply_server_message, client_change_document, client_close_document, + client_feature_request_canonical, client_initialize, client_open_document, client_shutdown, + ClientApplyServerMessageRequest, ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, LspClientDiagnostic, LspClientEvent, LspClientState, LspPosition, LspRange, }; +use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -112,29 +114,25 @@ impl LspHost { let mut session_state = Self::lock_session(&session)?; let state = session_state.clone(); let response = match request.action { - LspSessionAction::OpenDocument => { - lsp::client_open_document(ClientOpenDocumentRequest { - state, - uri: required(request.uri, "uri")?, - language_id: required(request.language_id, "languageId")?, - text: required(request.text, "text")?, - }) - } + LspSessionAction::OpenDocument => client_open_document(ClientOpenDocumentRequest { + state, + uri: required(request.uri, "uri")?, + language_id: required(request.language_id, "languageId")?, + text: required(request.text, "text")?, + }), LspSessionAction::ChangeDocument => { - lsp::client_change_document(ClientChangeDocumentRequest { + client_change_document(ClientChangeDocumentRequest { state, uri: required(request.uri, "uri")?, text: required(request.text, "text")?, }) } - LspSessionAction::CloseDocument => { - lsp::client_close_document(ClientCloseDocumentRequest { - state, - uri: required(request.uri, "uri")?, - }) - } - LspSessionAction::Shutdown => lsp::client_shutdown(ClientShutdownRequest { state }), - LspSessionAction::Request => lsp::client_feature_request(ClientFeatureRequest { + LspSessionAction::CloseDocument => client_close_document(ClientCloseDocumentRequest { + state, + uri: required(request.uri, "uri")?, + }), + LspSessionAction::Shutdown => client_shutdown(ClientShutdownRequest { state }), + LspSessionAction::Request => client_feature_request_canonical(ClientFeatureRequest { state, uri: required(request.uri, "uri")?, method: required(request.method, "method")?, @@ -147,7 +145,7 @@ impl LspHost { command: request.command, }), LspSessionAction::ApplyServerMessage => { - lsp::client_apply_server_message(ClientApplyServerMessageRequest { + client_apply_server_message(ClientApplyServerMessageRequest { state, message: required(request.message, "message")?, }) @@ -165,7 +163,7 @@ impl LspHost { } fn create(&self, request: LspSessionCommandRequest) -> Result { - let response = lsp::client_initialize(ClientInitializeRequest { + let response = client_initialize(ClientInitializeRequest { state: LspClientState::default(), root_uri: required(request.root_uri, "rootUri")?, process_id: request.process_id, diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs new file mode 100644 index 00000000..26d6c4d3 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -0,0 +1,13 @@ +//! Standard LSP contracts and the stateful client/session implementation. + +mod client; +mod host; +mod transport; +mod types; + +pub(crate) use client::*; +pub(crate) use host::{ + execute as session_execute_canonical, LspSessionCommandRequest, LspSessionResponse, +}; +pub(crate) use transport::*; +pub(crate) use types::*; diff --git a/rust/lithe-core/src/lsp/interface/transport.rs b/rust/lithe-core/src/lsp/interface/transport.rs new file mode 100644 index 00000000..4719caaf --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/transport.rs @@ -0,0 +1,88 @@ +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameMessageRequest { + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameMessageResponse { + pub frame: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseServerMessagesRequest { + #[serde(default)] + pub buffer: Vec, + #[serde(default)] + pub chunk: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseServerMessagesResponse { + pub buffer: Vec, + pub messages: Vec, +} +pub fn frame_message(request: FrameMessageRequest) -> Result { + if request.message.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP message frame cannot contain NUL bytes.", + )); + } + Ok(FrameMessageResponse { + frame: format!( + "Content-Length: {}\r\n\r\n{}", + request.message.len(), + request.message + ), + }) +} + +pub fn parse_server_messages( + request: ParseServerMessagesRequest, +) -> Result { + let mut buffer = request.buffer; + buffer.extend(request.chunk); + let mut messages = Vec::new(); + + while let Some(header_end) = find_header_end(&buffer) { + let header = String::from_utf8_lossy(&buffer[..header_end]); + let Some(content_length) = content_length_from_header(&header) else { + buffer.drain(..header_end + 4); + continue; + }; + let body_start = header_end + 4; + let body_end = body_start + content_length; + if buffer.len() < body_end { + break; + } + let body = buffer[body_start..body_end].to_vec(); + buffer.drain(..body_end); + if let Ok(message) = String::from_utf8(body) { + messages.push(message); + } + } + + Ok(ParseServerMessagesResponse { buffer, messages }) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn content_length_from_header(header: &str) -> Option { + header.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("content-length") { + value.trim().parse().ok() + } else { + None + } + }) +} diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs new file mode 100644 index 00000000..b935cad1 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -0,0 +1,196 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRange { + pub start: LspPosition, + pub end: LspPosition, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspPosition { + pub line: i64, + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspTextEditResponse { + pub range: LspRangeResponse, + pub new_text: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRangeResponse { + pub start: LspPositionResponse, + pub end: LspPositionResponse, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspPositionResponse { + pub line: i64, + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientState { + #[serde(default = "default_next_request_id")] + pub next_request_id: u64, + #[serde(default)] + pub initialized: bool, + #[serde(default)] + pub shutdown_requested: bool, + #[serde(default)] + pub server_capabilities: Vec, + #[serde(default)] + pub open_documents: BTreeMap, + #[serde(default)] + pub pending_requests: BTreeMap, + #[serde(default)] + pub diagnostics: BTreeMap>, +} + +impl Default for LspClientState { + fn default() -> Self { + Self { + next_request_id: default_next_request_id(), + initialized: false, + shutdown_requested: false, + server_capabilities: Vec::new(), + open_documents: BTreeMap::new(), + pending_requests: BTreeMap::new(), + diagnostics: BTreeMap::new(), + } + } +} + +fn default_next_request_id() -> u64 { + 1 +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDocument { + pub uri: String, + pub language_id: String, + pub version: i64, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnostic { + pub range: LspRangeResponse, + pub severity: Option, + pub message: String, + pub source: Option, + pub code: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInitializeRequest { + #[serde(default)] + pub state: LspClientState, + pub root_uri: String, + #[serde(default)] + pub process_id: Option, + #[serde(default)] + pub initialization_options: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientOpenDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub language_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientChangeDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCloseDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientShutdownRequest { + #[serde(default)] + pub state: LspClientState, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientFeatureRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub method: String, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, + #[serde(default)] + pub code_action: Option, + #[serde(default)] + pub command: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApplyServerMessageRequest { + #[serde(default)] + pub state: LspClientState, + pub message: String, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientResponse { + pub state: LspClientState, + pub messages: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientEvent { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/rust/lithe-core/src/lsp/languages/catalog.rs b/rust/lithe-core/src/lsp/languages/catalog.rs new file mode 100644 index 00000000..7517b94d --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/catalog.rs @@ -0,0 +1,332 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const BUILTIN_LANGUAGE_PROVIDERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/resources/lsp/language-providers.json" +)); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderCatalog { + pub version: u32, + pub providers: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderConfigDiagnostic { + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderDescriptor { + pub id: String, + pub display_name: String, + pub file_extensions: Vec, + pub file_names: Vec, + pub file_name_prefixes: Vec, + pub capabilities: Vec, + pub activation_policy: LspActivationPolicy, + pub language_id: Option, + pub language_ids_by_extension: BTreeMap, + pub language_ids_by_file_name: BTreeMap, + pub language_server_launch: Option, + pub language_server_installation: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspServerLaunchDescriptor { + pub executable_names: Vec, + #[serde(default)] + pub arguments: Vec, + #[serde(default)] + pub environment: BTreeMap, + #[serde(default)] + pub initialization_options: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspServerInstallationDescriptor { + #[serde(default)] + pub homebrew_formula: Option, + #[serde(default, rename = "officialDownloadURL")] + pub official_download_url: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspProviderCapability { + Run, + LanguageServer, + DebugAdapter, + Formatting, + Testing, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspActivationPolicy { + OnDemand, + Always, +} + +impl Default for LspActivationPolicy { + fn default() -> Self { + Self::OnDemand + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LspProviderConfigDocument { + #[serde(default, rename = "$schema")] + _schema: Option, + #[serde(default = "default_config_version")] + version: u32, + #[serde(default)] + providers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LspProviderPatch { + id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + file_extensions: Option>, + #[serde(default)] + file_names: Option>, + #[serde(default)] + file_name_prefixes: Option>, + #[serde(default)] + capabilities: Option>, + #[serde(default)] + activation_policy: Option, + #[serde(default)] + language_id: Option, + #[serde(default)] + language_ids_by_extension: Option>, + #[serde(default)] + language_ids_by_file_name: Option>, + #[serde(default)] + language_server_launch: Option, + #[serde(default)] + language_server_installation: Option, + #[serde(default)] + disabled: bool, +} +pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { + let catalog = provider_catalog(workspace_root); + serde_json::to_string(&catalog) + .unwrap_or_else(|_| "{\"version\":1,\"providers\":[]}".to_string()) +} + +pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { + let mut diagnostics = Vec::new(); + let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { + Ok(document) => document, + Err(message) => { + diagnostics.push(LspProviderConfigDiagnostic { + path: "builtin:lsp".to_string(), + message, + }); + LspProviderConfigDocument { + _schema: None, + version: 1, + providers: Vec::new(), + } + } + }; + + if let Some(root) = workspace_root { + let path = project_config_path(root); + if path.is_file() { + match std::fs::read_to_string(&path) { + Ok(raw) => match parse_document(&raw, &path.display().to_string()) { + Ok(project_document) => { + document = merge_documents(document, project_document); + } + Err(message) => diagnostics.push(LspProviderConfigDiagnostic { + path: path.display().to_string(), + message, + }), + }, + Err(error) => diagnostics.push(LspProviderConfigDiagnostic { + path: path.display().to_string(), + message: error.to_string(), + }), + } + } + } + + let mut providers = Vec::new(); + for patch in document.providers { + if patch.disabled { + continue; + } + providers.push(LspProviderDescriptor::from_patch(patch)); + } + LspProviderCatalog { + version: document.version, + providers, + diagnostics, + } +} + +fn parse_document(raw: &str, source: &str) -> Result { + serde_json::from_str(raw).map_err(|error| format!("{source}: {error}")) +} + +fn merge_documents( + mut base: LspProviderConfigDocument, + project: LspProviderConfigDocument, +) -> LspProviderConfigDocument { + base.version = project.version.max(base.version); + for patch in project.providers { + if let Some(existing) = base + .providers + .iter_mut() + .find(|provider| provider.id == patch.id) + { + existing.apply(patch); + } else { + base.providers.push(patch); + } + } + base +} + +fn project_config_path(root: &Path) -> PathBuf { + root.join(".lithe") + .join("lsp") + .join("language-providers.json") +} + +fn default_config_version() -> u32 { + 1 +} + +impl LspProviderPatch { + fn apply(&mut self, patch: LspProviderPatch) { + if patch.display_name.is_some() { + self.display_name = patch.display_name; + } + if patch.file_extensions.is_some() { + self.file_extensions = patch.file_extensions; + } + if patch.file_names.is_some() { + self.file_names = patch.file_names; + } + if patch.file_name_prefixes.is_some() { + self.file_name_prefixes = patch.file_name_prefixes; + } + if patch.capabilities.is_some() { + self.capabilities = patch.capabilities; + } + if patch.activation_policy.is_some() { + self.activation_policy = patch.activation_policy; + } + if patch.language_id.is_some() { + self.language_id = patch.language_id; + } + if patch.language_ids_by_extension.is_some() { + self.language_ids_by_extension = patch.language_ids_by_extension; + } + if patch.language_ids_by_file_name.is_some() { + self.language_ids_by_file_name = patch.language_ids_by_file_name; + } + if patch.language_server_launch.is_some() { + self.language_server_launch = patch.language_server_launch; + } + if patch.language_server_installation.is_some() { + self.language_server_installation = patch.language_server_installation; + } + self.disabled = patch.disabled; + } +} + +impl LspProviderDescriptor { + fn from_patch(patch: LspProviderPatch) -> Self { + let id = normalized_id(&patch.id); + let display_name = patch + .display_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| id.clone()); + let capabilities = patch.capabilities.unwrap_or_else(|| { + vec![ + LspProviderCapability::LanguageServer, + LspProviderCapability::Formatting, + ] + }); + Self { + id: id.clone(), + display_name, + file_extensions: normalized_values(patch.file_extensions.unwrap_or_default(), true), + file_names: normalized_values(patch.file_names.unwrap_or_default(), false), + file_name_prefixes: normalized_values( + patch.file_name_prefixes.unwrap_or_default(), + false, + ), + capabilities, + activation_policy: patch.activation_policy.unwrap_or_default(), + language_id: patch.language_id.filter(|value| !value.trim().is_empty()), + language_ids_by_extension: normalized_map( + patch.language_ids_by_extension.unwrap_or_default(), + true, + ), + language_ids_by_file_name: normalized_map( + patch.language_ids_by_file_name.unwrap_or_default(), + false, + ), + language_server_launch: patch.language_server_launch, + language_server_installation: patch.language_server_installation, + } + } +} + +fn normalized_id(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn normalized_values(values: Vec, trim_dot: bool) -> Vec { + let mut result = Vec::new(); + for value in values { + let normalized = normalized_key(&value, trim_dot); + if !normalized.is_empty() && !result.contains(&normalized) { + result.push(normalized); + } + } + result +} + +fn normalized_map(values: BTreeMap, trim_dot: bool) -> BTreeMap { + values + .into_iter() + .filter_map(|(key, value)| { + let key = normalized_key(&key, trim_dot); + if key.is_empty() || value.trim().is_empty() { + None + } else { + Some((key, value)) + } + }) + .collect() +} + +fn normalized_key(value: &str, trim_dot: bool) -> String { + let mut value = value.trim().to_ascii_lowercase(); + if trim_dot { + value = value.trim_start_matches('.').to_string(); + } + value +} diff --git a/rust/lithe-core/src/lsp/languages/mod.rs b/rust/lithe-core/src/lsp/languages/mod.rs new file mode 100644 index 00000000..cb9bd6d0 --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/mod.rs @@ -0,0 +1,6 @@ +//! Dynamic provider metadata and host-model adapters for individual languages. + +mod catalog; +pub(crate) mod swift; + +pub(crate) use catalog::*; diff --git a/rust/lithe-core/src/lsp/languages/swift.rs b/rust/lithe-core/src/lsp/languages/swift.rs new file mode 100644 index 00000000..29c13918 --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/swift.rs @@ -0,0 +1,189 @@ +use crate::lsp::interface::{ClientFeatureRequest, LspSessionCommandRequest}; +use serde_json::{json, Value}; + +pub(crate) fn adapt_feature_request(mut request: ClientFeatureRequest) -> ClientFeatureRequest { + request.completion_item = request + .completion_item + .as_ref() + .map(swift_completion_item_to_lsp); + request.code_action = request.code_action.as_ref().map(swift_code_action_to_lsp); + request.command = request.command.as_ref().and_then(swift_command_to_lsp); + request +} + +pub(crate) fn adapt_session_request( + mut request: LspSessionCommandRequest, +) -> LspSessionCommandRequest { + request.completion_item = request + .completion_item + .as_ref() + .map(swift_completion_item_to_lsp); + request.code_action = request.code_action.as_ref().map(swift_code_action_to_lsp); + request.command = request.command.as_ref().and_then(swift_command_to_lsp); + request +} + +pub(crate) fn swift_completion_item_to_lsp(item: &Value) -> Value { + let mut object = serde_json::Map::new(); + copy_string_field(item, &mut object, "label"); + copy_string_field(item, &mut object, "detail"); + copy_string_field(item, &mut object, "documentation"); + copy_string_field(item, &mut object, "insertText"); + copy_string_field(item, &mut object, "sortText"); + copy_string_field(item, &mut object, "filterText"); + if let Some(kind) = item.get("kind").and_then(Value::as_i64) { + object.insert("kind".to_string(), json!(kind)); + } + if let Some(edit) = item.get("textEdit").and_then(swift_text_edit_to_lsp) { + object.insert("textEdit".to_string(), edit); + } + if let Some(edits) = item.get("additionalTextEdits").and_then(Value::as_array) { + object.insert( + "additionalTextEdits".to_string(), + json!(edits + .iter() + .filter_map(swift_text_edit_to_lsp) + .collect::>()), + ); + } + if let Some(data) = item.get("data") { + object.insert("data".to_string(), data.clone()); + } + Value::Object(object) +} + +pub(crate) fn swift_code_action_to_lsp(action: &Value) -> Value { + let mut object = serde_json::Map::new(); + copy_string_field(action, &mut object, "title"); + copy_string_field(action, &mut object, "kind"); + if let Some(is_preferred) = action.get("isPreferred").and_then(Value::as_bool) { + object.insert("isPreferred".to_string(), json!(is_preferred)); + } + if let Some(edit) = action.get("edit").and_then(swift_workspace_edit_to_lsp) { + object.insert("edit".to_string(), edit); + } + if let Some(command) = action.get("command").and_then(swift_command_to_lsp) { + object.insert("command".to_string(), command); + } + if let Some(data) = action.get("data") { + object.insert("data".to_string(), data.clone()); + } + Value::Object(object) +} + +fn swift_workspace_edit_to_lsp(value: &Value) -> Option { + let changes = value.get("changes")?.as_object()?; + let mut parsed_changes = serde_json::Map::new(); + for (path, edits) in changes { + let uri = if path.starts_with("file://") { + path.clone() + } else { + format!("file://{path}") + }; + parsed_changes.insert( + uri, + json!(edits + .as_array() + .map(|values| values + .iter() + .filter_map(swift_text_edit_to_lsp) + .collect::>()) + .unwrap_or_default()), + ); + } + Some(json!({ "changes": parsed_changes })) +} + +pub(crate) fn swift_command_to_lsp(value: &Value) -> Option { + Some(json!({ + "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), + "command": value.get("command").and_then(Value::as_str)?, + "arguments": value + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + })) +} + +fn copy_string_field(source: &Value, target: &mut serde_json::Map, field: &str) { + if let Some(value) = source.get(field).and_then(Value::as_str) { + target.insert(field.to_string(), json!(value)); + } +} + +fn swift_text_edit_to_lsp(value: &Value) -> Option { + Some(json!({ + "range": swift_range_to_lsp(value.get("range")?)?, + "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() + })) +} + +fn swift_range_to_lsp(value: &Value) -> Option { + Some(json!({ + "start": swift_position_to_lsp(value.get("start")?)?, + "end": swift_position_to_lsp(value.get("end")?)? + })) +} + +fn swift_position_to_lsp(value: &Value) -> Option { + Some(json!({ + "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), + "character": value + .get("utf16Column") + .or_else(|| value.get("character")) + .and_then(Value::as_i64) + .unwrap_or(0) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_items_translate_swift_utf16_positions() { + let item = json!({ + "label": "print", + "data": { "token": 7 }, + "textEdit": { + "range": { + "start": { "line": 2, "utf16Column": 3 }, + "end": { "line": 2, "utf16Column": 5 } + }, + "newText": "print()" + } + }); + + let converted = swift_completion_item_to_lsp(&item); + assert_eq!(converted["textEdit"]["range"]["start"]["character"], 3); + assert_eq!(converted["textEdit"]["newText"], "print()"); + assert_eq!(converted["data"]["token"], 7); + } + + #[test] + fn code_actions_translate_workspace_paths_to_file_uris() { + let action = json!({ + "title": "Apply fix", + "edit": { + "changes": { + "/tmp/main.swift": [{ + "range": { + "start": { "line": 0, "utf16Column": 0 }, + "end": { "line": 0, "utf16Column": 1 } + }, + "newText": "x" + }] + } + } + }); + + let converted = swift_code_action_to_lsp(&action); + assert!(converted["edit"]["changes"]["file:///tmp/main.swift"].is_array()); + } + + #[test] + fn commands_require_a_command_identifier() { + assert!(swift_command_to_lsp(&json!({ "title": "Missing" })).is_none()); + } +} diff --git a/rust/lithe-core/src/lsp/lightweight/edits.rs b/rust/lithe-core/src/lsp/lightweight/edits.rs new file mode 100644 index 00000000..6dda78d6 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/edits.rs @@ -0,0 +1,157 @@ +use crate::lsp::interface::{LspPosition, LspPositionResponse, LspRange, LspRangeResponse}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyTextEditsRequest { + pub text: String, + #[serde(default)] + pub edits: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspTextEdit { + pub range: LspRange, + pub new_text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextResponse { + pub text: String, +} + +pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result { + let mut replacements = Vec::new(); + for edit in request.edits { + let start = utf16_position_to_byte_offset(&request.text, edit.range.start)?; + let end = utf16_position_to_byte_offset(&request.text, edit.range.end)?; + if end < start { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange")); + } + replacements.push((start, end, edit.new_text)); + } + replacements.sort_by_key(|(start, _, _)| *start); + for pair in replacements.windows(2) { + if pair[0].1 > pair[1].0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned overlapping text edits.", + ) + .with_details("overlappingEdits")); + } + } + + let mut text = request.text; + for (start, end, replacement) in replacements.into_iter().rev() { + text.replace_range(start..end, &replacement); + } + Ok(TextResponse { text }) +} + +pub(super) fn utf16_position_to_byte_offset( + text: &str, + position: LspPosition, +) -> Result { + if position.line < 0 || position.utf16_column < 0 { + return Err(invalid_range_error()); + } + let line = usize::try_from(position.line).map_err(|_| invalid_range_error())?; + let column = usize::try_from(position.utf16_column).map_err(|_| invalid_range_error())?; + let Some((start, contents_end)) = line_bounds(text, line) else { + return Err(invalid_range_error()); + }; + Ok(byte_offset_for_utf16_column( + text, + start, + contents_end, + column, + )) +} + +fn invalid_range_error() -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange") +} + +fn line_bounds(text: &str, target_line: usize) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + let mut line = 0; + let mut start = 0; + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'\n' { + if line == target_line { + let contents_end = if index > start && bytes[index - 1] == b'\r' { + index - 1 + } else { + index + }; + return Some((start, contents_end)); + } + line += 1; + start = index + 1; + } + } + if line == target_line { + Some((start, text.len())) + } else { + None + } +} + +fn byte_offset_for_utf16_column( + text: &str, + start: usize, + contents_end: usize, + column: usize, +) -> usize { + let mut units = 0; + for (relative, character) in text[start..contents_end].char_indices() { + let next_units = units + character.len_utf16(); + if next_units > column { + return start + relative; + } + units = next_units; + if units == column { + return start + relative + character.len_utf8(); + } + } + contents_end +} + +fn byte_offset_to_lsp_position(text: &str, offset: usize) -> LspPositionResponse { + let offset = offset.min(text.len()); + let mut line = 0_i64; + let mut column = 0_i64; + for (index, character) in text.char_indices() { + if index >= offset { + break; + } + if character == '\n' { + line += 1; + column = 0; + } else { + column += character.len_utf16() as i64; + } + } + LspPositionResponse { + line, + utf16_column: column, + } +} + +pub(super) fn range_for_offsets(text: &str, start: usize, end: usize) -> LspRangeResponse { + LspRangeResponse { + start: byte_offset_to_lsp_position(text, start), + end: byte_offset_to_lsp_position(text, end), + } +} diff --git a/rust/lithe-core/src/lsp/lightweight/mod.rs b/rust/lithe-core/src/lsp/lightweight/mod.rs new file mode 100644 index 00000000..9bf3c923 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/mod.rs @@ -0,0 +1,9 @@ +//! In-process language features that remain available without an LSP server. + +mod edits; +mod snippets; +mod symbols; + +pub(crate) use edits::*; +pub(crate) use snippets::*; +pub(crate) use symbols::*; diff --git a/rust/lithe-core/src/lsp/lightweight/snippets.rs b/rust/lithe-core/src/lsp/lightweight/snippets.rs new file mode 100644 index 00000000..92f3c89d --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/snippets.rs @@ -0,0 +1,78 @@ +use super::edits::TextResponse; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlainSnippetRequest { + pub value: String, +} + +pub fn plain_snippet(request: PlainSnippetRequest) -> TextResponse { + TextResponse { + text: snippet_plain_text(&request.value), + } +} + +pub(crate) fn snippet_plain_text(value: &str) -> String { + let mut output = String::new(); + let mut chars = value.chars().peekable(); + while let Some(character) = chars.next() { + if character != '$' { + output.push(character); + continue; + } + match chars.peek().copied() { + Some('{') => { + chars.next(); + if !consume_digits(&mut chars) { + output.push_str("${"); + continue; + } + match chars.peek().copied() { + Some(':') => { + chars.next(); + output.push_str(&consume_until_placeholder_end(&mut chars)); + } + Some('}') => { + chars.next(); + } + _ => output.push('$'), + } + } + Some(next) if next.is_ascii_digit() => { + consume_digits(&mut chars); + } + _ => output.push('$'), + } + } + output +} + +fn consume_digits(chars: &mut std::iter::Peekable) -> bool +where + I: Iterator, +{ + let mut consumed = false; + while chars + .peek() + .is_some_and(|character| character.is_ascii_digit()) + { + chars.next(); + consumed = true; + } + consumed +} + +fn consume_until_placeholder_end(chars: &mut std::iter::Peekable) -> String +where + I: Iterator, +{ + let mut value = String::new(); + for character in chars.by_ref() { + if character == '}' { + break; + } + value.push(character); + } + value +} diff --git a/rust/lithe-core/src/lsp/lightweight/symbols.rs b/rust/lithe-core/src/lsp/lightweight/symbols.rs new file mode 100644 index 00000000..0d1c00c0 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/symbols.rs @@ -0,0 +1,365 @@ +use super::edits::{range_for_offsets, utf16_position_to_byte_offset}; +use crate::lsp::interface::{ + LspPosition, LspPositionResponse, LspRangeResponse, LspTextEditResponse, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinRequest { + pub file_path: String, + pub text: String, + pub position: LspPosition, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinNavigationRequest { + pub file_path: String, + pub text: String, + pub position: LspPosition, + pub method: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinCompletionResponse { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinCompletionItem { + pub label: String, + pub insert_text: String, + pub kind: Option, + pub detail: Option, + pub text_edit: LspTextEditResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinHoverResponse { + pub hover: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinHover { + pub contents: String, + pub is_markdown: bool, + pub range: LspRangeResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinNavigationResponse { + pub locations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinLocation { + pub file_path: String, + pub range: LspRangeResponse, + pub is_read_only: bool, + pub display_path: Option, +} + +#[derive(Debug, Clone)] +struct IdentifierOccurrence { + value: String, + start: usize, + end: usize, + range: LspRangeResponse, +} + +pub fn builtin_completions( + request: BuiltinRequest, +) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let prefix = identifier_prefix_at(&request.text, cursor); + let start_column = request.position.utf16_column - prefix.encode_utf16().count() as i64; + let replacement_range = LspRangeResponse { + start: LspPositionResponse { + line: request.position.line, + utf16_column: start_column.max(0), + }, + end: LspPositionResponse { + line: request.position.line, + utf16_column: request.position.utf16_column, + }, + }; + + let mut seen = BTreeMap::::new(); + for occurrence in identifier_occurrences(&request.text) { + if occurrence.value == prefix { + continue; + } + if !prefix.is_empty() && !occurrence.value.starts_with(&prefix) { + continue; + } + let kind = builtin_completion_kind(&request.text, occurrence.start); + seen.entry(occurrence.value).or_insert(kind); + } + + let items = seen + .into_iter() + .take(80) + .map(|(label, kind)| BuiltinCompletionItem { + insert_text: label.clone(), + label, + kind: Some(kind), + detail: Some("Current file symbol".to_string()), + text_edit: LspTextEditResponse { + range: replacement_range, + new_text: String::new(), + }, + }) + .map(|mut item| { + item.text_edit.new_text = item.insert_text.clone(); + item + }) + .collect(); + Ok(BuiltinCompletionResponse { items }) +} + +pub fn builtin_hover(request: BuiltinRequest) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let Some(identifier) = identifier_at(&request.text, cursor) else { + return Ok(BuiltinHoverResponse { hover: None }); + }; + Ok(BuiltinHoverResponse { + hover: Some(BuiltinHover { + contents: format!("`{}`", identifier.value), + is_markdown: true, + range: identifier.range, + }), + }) +} + +pub fn builtin_navigation( + request: BuiltinNavigationRequest, +) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let Some(identifier) = identifier_at(&request.text, cursor) else { + return Ok(BuiltinNavigationResponse { + locations: Vec::new(), + }); + }; + let mut occurrences: Vec<_> = identifier_occurrences(&request.text) + .into_iter() + .filter(|occurrence| occurrence.value == identifier.value) + .collect(); + + if request.method == "textDocument/definition" + || request.method == "textDocument/declaration" + || request.method == "textDocument/typeDefinition" + { + let declarations: Vec<_> = occurrences + .iter() + .filter(|occurrence| looks_like_declaration(&request.text, occurrence.start)) + .cloned() + .collect(); + if !declarations.is_empty() { + occurrences = declarations; + } + } else if request.method == "textDocument/implementation" { + occurrences.retain(|occurrence| occurrence.start != identifier.start); + } + + let locations = occurrences + .into_iter() + .take(200) + .map(|occurrence| BuiltinLocation { + file_path: request.file_path.clone(), + range: occurrence.range, + is_read_only: false, + display_path: None, + }) + .collect(); + Ok(BuiltinNavigationResponse { locations }) +} + +fn identifier_occurrences(text: &str) -> Vec { + let mut values = Vec::new(); + let mut current_start: Option = None; + for (index, character) in text.char_indices() { + if is_identifier_character(character) { + if current_start.is_none() { + current_start = Some(index); + } + } else if let Some(start) = current_start.take() { + push_identifier(text, start, index, &mut values); + } + } + if let Some(start) = current_start { + push_identifier(text, start, text.len(), &mut values); + } + values +} + +fn push_identifier(text: &str, start: usize, end: usize, values: &mut Vec) { + let value = &text[start..end]; + if value.chars().next().is_some_and(is_identifier_start) + && !is_language_keyword(value) + && value.len() <= 120 + { + values.push(IdentifierOccurrence { + value: value.to_string(), + start, + end, + range: range_for_offsets(text, start, end), + }); + } +} + +fn identifier_at(text: &str, cursor: usize) -> Option { + identifier_occurrences(text) + .into_iter() + .find(|occurrence| occurrence.start <= cursor && cursor <= occurrence.end) +} + +fn identifier_prefix_at(text: &str, cursor: usize) -> String { + let mut start = cursor.min(text.len()); + while start > 0 { + let Some((previous_index, previous)) = text[..start].char_indices().next_back() else { + break; + }; + if !is_identifier_character(previous) { + break; + } + start = previous_index; + } + text[start..cursor.min(text.len())].to_string() +} + +fn is_identifier_start(character: char) -> bool { + character == '_' || character.is_alphabetic() +} + +fn is_identifier_character(character: char) -> bool { + character == '_' || character.is_alphanumeric() +} + +fn is_language_keyword(value: &str) -> bool { + matches!( + value, + "as" | "async" + | "await" + | "break" + | "case" + | "catch" + | "class" + | "const" + | "continue" + | "def" + | "default" + | "defer" + | "do" + | "else" + | "enum" + | "export" + | "extends" + | "false" + | "final" + | "fn" + | "for" + | "func" + | "function" + | "if" + | "impl" + | "import" + | "in" + | "interface" + | "let" + | "match" + | "mod" + | "mut" + | "nil" + | "null" + | "package" + | "private" + | "protected" + | "public" + | "return" + | "self" + | "static" + | "struct" + | "switch" + | "this" + | "throw" + | "throws" + | "trait" + | "true" + | "try" + | "type" + | "var" + | "while" + ) +} + +fn builtin_completion_kind(text: &str, start: usize) -> i32 { + if looks_like_declaration_with_keywords( + text, + start, + &["class", "struct", "enum", "interface", "trait"], + ) { + 7 + } else if looks_like_declaration_with_keywords(text, start, &["func", "function", "def", "fn"]) + { + 3 + } else { + 6 + } +} + +fn looks_like_declaration(text: &str, start: usize) -> bool { + looks_like_declaration_with_keywords( + text, + start, + &[ + "class", + "struct", + "enum", + "interface", + "trait", + "func", + "function", + "def", + "fn", + "let", + "var", + "const", + "type", + ], + ) +} + +fn looks_like_declaration_with_keywords(text: &str, start: usize, keywords: &[&str]) -> bool { + let line_start = text[..start].rfind('\n').map_or(0, |index| index + 1); + let prefix = &text[line_start..start]; + let tokens: Vec<&str> = prefix + .split(|character: char| !is_identifier_character(character)) + .filter(|token| !token.is_empty()) + .collect(); + tokens + .last() + .is_some_and(|token| keywords.iter().any(|keyword| keyword == token)) +} + +fn validate_file_path(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP builtin request requires a file path.", + )) + } else { + Ok(()) + } +} diff --git a/rust/lithe-core/src/lsp/mod.rs b/rust/lithe-core/src/lsp/mod.rs new file mode 100644 index 00000000..ee7ad3a5 --- /dev/null +++ b/rust/lithe-core/src/lsp/mod.rs @@ -0,0 +1,24 @@ +//! Language tooling is split by responsibility while this facade keeps the Core command API stable. + +pub(crate) mod interface; +mod languages; +pub(crate) mod lightweight; + +pub(crate) use interface::*; +pub(crate) use languages::*; +pub(crate) use lightweight::*; + +pub(crate) fn client_feature_request( + request: ClientFeatureRequest, +) -> Result { + interface::client_feature_request_canonical(languages::swift::adapt_feature_request(request)) +} + +pub(crate) fn session_execute( + request: LspSessionCommandRequest, +) -> Result { + interface::session_execute_canonical(languages::swift::adapt_session_request(request)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs new file mode 100644 index 00000000..d5aeb8bd --- /dev/null +++ b/rust/lithe-core/src/lsp/tests.rs @@ -0,0 +1,1210 @@ +use super::*; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!("lithe-lsp-{label}-{}-{nonce}", std::process::id())) +} + +#[test] +fn builtin_catalog_describes_market_lsp_providers() { + let catalog = provider_catalog(None); + let ids: Vec<_> = catalog + .providers + .iter() + .map(|provider| provider.id.as_str()) + .collect(); + assert!(ids.starts_with(&["java", "go", "python", "node", "rust"])); + assert!(ids.contains(&"swift")); + assert!(ids.contains(&"clangd")); + assert!(ids.contains(&"dockerfile")); + assert!(ids.contains(&"graphql")); + let clangd = catalog + .providers + .iter() + .find(|provider| provider.id == "clangd") + .expect("clangd provider should exist"); + assert_eq!( + clangd.language_ids_by_extension.get("m"), + Some(&"objective-c".to_string()) + ); + let swift = catalog + .providers + .iter() + .find(|provider| provider.id == "swift") + .expect("swift provider should exist"); + let swift_launch = swift + .language_server_launch + .as_ref() + .expect("swift launch descriptor should exist"); + assert_eq!( + swift_launch.executable_names, + vec!["sourcekit-lsp".to_string()] + ); + let go = catalog + .providers + .iter() + .find(|provider| provider.id == "go") + .expect("go provider should exist"); + let go_installation = go + .language_server_installation + .as_ref() + .expect("go installation descriptor should exist"); + assert_eq!(go_installation.homebrew_formula.as_deref(), Some("gopls")); + assert_eq!( + go_installation.official_download_url.as_deref(), + Some("https://go.dev/gopls/") + ); +} + +#[test] +fn project_config_extends_and_overrides_builtin_catalog() { + let root = temporary_root("project-config"); + fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); + fs::write( + root.join(".lithe/lsp/language-providers.json"), + r#"{ + "version": 1, + "providers": [ + { + "id": "roc", + "displayName": "Roc", + "fileExtensions": ["roc"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "roc" + }, + { + "id": "swift", + "fileExtensions": ["swift", "swiftinterface"], + "languageServerLaunch": { + "executableNames": ["custom-sourcekit-lsp"], + "arguments": ["--stdio"], + "environment": { + "SOURCEKIT_TOOLCHAIN": "custom" + }, + "initializationOptions": { + "indexing": true + } + }, + "languageServerInstallation": { + "homebrewFormula": "custom-sourcekit-lsp", + "officialDownloadURL": "https://example.com/sourcekit-lsp" + } + }, + { + "id": "perl", + "disabled": true + } + ] + }"#, + ) + .unwrap(); + + let catalog = provider_catalog(Some(&root)); + assert!(catalog + .providers + .iter() + .any(|provider| provider.id == "roc")); + let swift = catalog + .providers + .iter() + .find(|provider| provider.id == "swift") + .expect("swift provider should still exist"); + assert!(swift + .file_extensions + .contains(&"swiftinterface".to_string())); + let swift_launch = swift + .language_server_launch + .as_ref() + .expect("swift launch descriptor should be overridden"); + assert_eq!( + swift_launch.executable_names, + vec!["custom-sourcekit-lsp".to_string()] + ); + assert_eq!(swift_launch.arguments, vec!["--stdio".to_string()]); + assert_eq!( + swift_launch.environment.get("SOURCEKIT_TOOLCHAIN"), + Some(&"custom".to_string()) + ); + assert_eq!( + swift_launch.initialization_options, + Some(json!({ "indexing": true })) + ); + let swift_installation = swift + .language_server_installation + .as_ref() + .expect("swift installation descriptor should be overridden"); + assert_eq!( + swift_installation.homebrew_formula.as_deref(), + Some("custom-sourcekit-lsp") + ); + assert!(!catalog + .providers + .iter() + .any(|provider| provider.id == "perl")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn ffi_json_is_a_standalone_catalog_document() { + let raw = provider_catalog_json(None); + let value: Value = serde_json::from_str(&raw).expect("catalog should be JSON"); + assert_eq!(value["version"], 2); + assert!(value["providers"].as_array().unwrap().len() > 10); + assert!(value.get("ok").is_none()); + assert!(value.get("command").is_none()); +} + +#[test] +fn project_catalog_reports_unknown_configuration_fields() { + let root = temporary_root("project-config-unknown-field"); + fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); + fs::write( + root.join(".lithe/lsp/language-providers.json"), + r#"{ + "version": 2, + "providers": [{ "id": "go", "languageServerLanch": {} }] + }"#, + ) + .unwrap(); + + let catalog = provider_catalog(Some(&root)); + assert_eq!(catalog.diagnostics.len(), 1); + assert!(catalog.diagnostics[0].message.contains("unknown field")); + assert!(catalog.providers.iter().any(|provider| provider.id == "go")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn text_edits_use_lsp_utf16_positions() { + let response = apply_text_edits(ApplyTextEditsRequest { + text: "one 😀\ntwo three\n".to_string(), + edits: vec![ + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 4, + }, + end: LspPosition { + line: 0, + utf16_column: 6, + }, + }, + new_text: "rocket".to_string(), + }, + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 1, + utf16_column: 4, + }, + end: LspPosition { + line: 1, + utf16_column: 9, + }, + }, + new_text: "four".to_string(), + }, + ], + }) + .unwrap(); + + assert_eq!(response.text, "one rocket\ntwo four\n"); +} + +#[test] +fn text_edits_reject_invalid_and_overlapping_ranges() { + let invalid = apply_text_edits(ApplyTextEditsRequest { + text: "one line".to_string(), + edits: vec![LspTextEdit { + range: LspRange { + start: LspPosition { + line: 9, + utf16_column: 0, + }, + end: LspPosition { + line: 9, + utf16_column: 1, + }, + }, + new_text: "x".to_string(), + }], + }) + .unwrap_err(); + assert_eq!(invalid.details.as_deref(), Some("invalidRange")); + + let overlapping = apply_text_edits(ApplyTextEditsRequest { + text: "one line".to_string(), + edits: vec![ + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 4, + }, + }, + new_text: "a".to_string(), + }, + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 2, + }, + end: LspPosition { + line: 0, + utf16_column: 6, + }, + }, + new_text: "b".to_string(), + }, + ], + }) + .unwrap_err(); + assert_eq!(overlapping.details.as_deref(), Some("overlappingEdits")); +} + +#[test] +fn snippet_plain_text_removes_tab_stops_and_keeps_defaults() { + assert_eq!(snippet_plain_text("print(${1:value})$0"), "print(value)"); + assert_eq!(snippet_plain_text("${1:let} ${2:name} = $3"), "let name = "); +} + +#[test] +fn builtin_completion_returns_current_file_identifiers_for_prefix() { + let response = builtin_completions(BuiltinRequest { + file_path: "/tmp/main.swift".to_string(), + text: "struct RocketShip {}\nlet rocketSpeed = Roc\n".to_string(), + position: LspPosition { + line: 1, + utf16_column: 19, + }, + }) + .unwrap(); + + assert!(response.items.iter().any(|item| item.label == "RocketShip")); + let item = response + .items + .iter() + .find(|item| item.label == "RocketShip") + .unwrap(); + assert_eq!(item.text_edit.range.start.utf16_column, 18); + assert_eq!(item.text_edit.new_text, "RocketShip"); +} + +#[test] +fn builtin_hover_returns_current_identifier_range() { + let response = builtin_hover(BuiltinRequest { + file_path: "/tmp/main.rs".to_string(), + text: "fn launch() {}\n".to_string(), + position: LspPosition { + line: 0, + utf16_column: 4, + }, + }) + .unwrap(); + + let hover = response.hover.unwrap(); + assert_eq!(hover.contents, "`launch`"); + assert_eq!(hover.range.start.utf16_column, 3); + assert_eq!(hover.range.end.utf16_column, 9); +} + +#[test] +fn builtin_navigation_prefers_declarations_and_finds_references() { + let text = "let service = 1\nprint(service)\n"; + let definitions = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/main.swift".to_string(), + text: text.to_string(), + position: LspPosition { + line: 1, + utf16_column: 8, + }, + method: "textDocument/definition".to_string(), + }) + .unwrap(); + assert_eq!(definitions.locations.len(), 1); + assert_eq!(definitions.locations[0].range.start.line, 0); + assert_eq!(definitions.locations[0].range.start.utf16_column, 4); + + let references = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/main.swift".to_string(), + text: text.to_string(), + position: LspPosition { + line: 1, + utf16_column: 8, + }, + method: "textDocument/references".to_string(), + }) + .unwrap(); + assert_eq!(references.locations.len(), 2); +} + +#[test] +fn file_uri_paths_decode_spaces_and_utf8_characters() { + assert_eq!( + file_path_from_uri("file:///tmp/go%20project/%E4%B8%AD%E6%96%87/main.go"), + "/tmp/go project/中文/main.go" + ); +} + +#[test] +fn client_core_initializes_and_applies_server_capabilities() { + let initialized = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: "file:///tmp/project".to_string(), + process_id: Some(42), + initialization_options: Some(json!({ + "ui.semanticTokens": true + })), + }) + .unwrap(); + assert_eq!( + initialized.state.pending_requests.get("1").unwrap(), + "initialize" + ); + let initialize_message: Value = + serde_json::from_str(&initialized.messages[0]).expect("initialize JSON"); + assert_eq!(initialize_message["method"], "initialize"); + assert_eq!( + initialize_message["params"]["rootUri"], + "file:///tmp/project" + ); + let client_capabilities = &initialize_message["params"]["capabilities"]; + assert_eq!(client_capabilities["workspace"]["configuration"], true); + assert_eq!( + client_capabilities["textDocument"]["completion"]["completionItem"]["snippetSupport"], + false + ); + assert_eq!( + initialize_message["params"]["initializationOptions"]["ui.semanticTokens"], + true + ); + assert!(client_capabilities["workspace"].get("applyEdit").is_none()); + assert!(client_capabilities["textDocument"]["synchronization"] + .get("didSave") + .is_none()); + assert_eq!(client_capabilities["window"]["workDoneProgress"], true); + + let applied = client_apply_server_message(ClientApplyServerMessageRequest { + state: initialized.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "capabilities": { + "definitionProvider": true, + "hoverProvider": true, + "completionProvider": { "resolveProvider": true }, + "codeActionProvider": { "resolveProvider": true } + } + } + }"# + .to_string(), + }) + .unwrap(); + + assert!(applied.state.initialized); + assert!(applied.state.pending_requests.is_empty()); + assert!(applied + .state + .server_capabilities + .contains(&"definition".to_string())); + assert!(applied + .state + .server_capabilities + .contains(&"completionResolve".to_string())); + assert_eq!(applied.messages.len(), 1); + let initialized_notification: Value = + serde_json::from_str(&applied.messages[0]).expect("initialized JSON"); + assert_eq!(initialized_notification["method"], "initialized"); +} + +#[test] +fn client_core_tracks_documents_and_feature_requests() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() {}\n".to_string(), + }) + .unwrap(); + assert_eq!( + opened + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .version, + 1 + ); + let did_open: Value = serde_json::from_str(&opened.messages[0]).unwrap(); + assert_eq!(did_open["method"], "textDocument/didOpen"); + + let changed = client_change_document(ClientChangeDocumentRequest { + state: opened.state, + uri: "file:///tmp/project/main.rs".to_string(), + text: "fn main() { launch(); }\n".to_string(), + }) + .unwrap(); + assert_eq!( + changed + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .version, + 2 + ); + let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); + assert_eq!(did_change["method"], "textDocument/didChange"); + + let requested = client_feature_request(ClientFeatureRequest { + state: changed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/definition".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 12, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + assert_eq!( + requested.state.pending_requests.get("1").unwrap(), + "textDocument/definition" + ); + let request_message: Value = serde_json::from_str(&requested.messages[0]).unwrap(); + assert_eq!(request_message["method"], "textDocument/definition"); + assert_eq!(request_message["params"]["position"]["character"], 12); +} + +#[test] +fn client_core_closes_open_documents() { + let uri = "file:///tmp/project/main.go"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "go".to_string(), + text: "package main\n".to_string(), + }) + .unwrap(); + + let closed = client_close_document(ClientCloseDocumentRequest { + state: opened.state, + uri: uri.to_string(), + }) + .unwrap(); + + assert!(!closed.state.open_documents.contains_key(uri)); + assert_eq!(closed.messages.len(), 1); + let did_close: Value = serde_json::from_str(&closed.messages[0]).unwrap(); + assert_eq!( + did_close, + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": { + "textDocument": { + "uri": uri + } + } + }) + ); + + let error = client_close_document(ClientCloseDocumentRequest { + state: closed.state, + uri: uri.to_string(), + }) + .unwrap_err(); + assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); +} + +#[test] +fn client_core_waits_for_shutdown_response_before_exiting() { + let mut state = LspClientState { + initialized: true, + ..LspClientState::default() + }; + state.server_capabilities.push("completion".to_string()); + state.open_documents.insert( + "file:///tmp/project/main.go".to_string(), + LspClientDocument { + uri: "file:///tmp/project/main.go".to_string(), + language_id: "go".to_string(), + version: 1, + text: "package main\n".to_string(), + }, + ); + + let shutting_down = client_shutdown(ClientShutdownRequest { state }).unwrap(); + assert!(shutting_down.state.shutdown_requested); + assert_eq!( + shutting_down.state.pending_requests.get("1"), + Some(&"shutdown".to_string()) + ); + let shutdown: Value = serde_json::from_str(&shutting_down.messages[0]).unwrap(); + assert_eq!( + shutdown, + json!({ + "jsonrpc": "2.0", + "id": "1", + "method": "shutdown" + }) + ); + + let exited = client_apply_server_message(ClientApplyServerMessageRequest { + state: shutting_down.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": null + }) + .to_string(), + }) + .unwrap(); + + assert!(!exited.state.initialized); + assert!(!exited.state.shutdown_requested); + assert!(exited.state.pending_requests.is_empty()); + assert!(exited.state.server_capabilities.is_empty()); + assert!(exited.state.open_documents.is_empty()); + assert_eq!(exited.messages.len(), 1); + let exit: Value = serde_json::from_str(&exited.messages[0]).unwrap(); + assert_eq!( + exit, + json!({ + "jsonrpc": "2.0", + "method": "exit" + }) + ); + assert_eq!(exited.events.len(), 1); + assert_eq!(exited.events[0].method.as_deref(), Some("shutdown")); +} + +#[test] +fn client_core_rejects_duplicate_shutdown_requests() { + let shutting_down = client_shutdown(ClientShutdownRequest { + state: LspClientState::default(), + }) + .unwrap(); + + let error = client_shutdown(ClientShutdownRequest { + state: shutting_down.state, + }) + .unwrap_err(); + assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); +} + +#[test] +fn frame_message_uses_lsp_content_length_bytes() { + let message = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"你好"}}"#; + let framed = frame_message(FrameMessageRequest { + message: message.to_string(), + }) + .unwrap(); + assert!(framed + .frame + .starts_with(&format!("Content-Length: {}\r\n\r\n", message.len()))); + assert!(framed.frame.ends_with(message)); +} + +#[test] +fn parse_server_messages_returns_complete_messages_and_remaining_buffer() { + let first = r#"{"jsonrpc":"2.0","id":1,"result":null}"#; + let second = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"ok"}}"#; + let first_frame = frame_message(FrameMessageRequest { + message: first.to_string(), + }) + .unwrap() + .frame; + let second_frame = frame_message(FrameMessageRequest { + message: second.to_string(), + }) + .unwrap() + .frame; + let split_at = first_frame.len() - 3; + let partial = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: first_frame.as_bytes()[..split_at].to_vec(), + }) + .unwrap(); + assert!(partial.messages.is_empty()); + assert_eq!(partial.buffer, first_frame.as_bytes()[..split_at]); + + let mut next_chunk = first_frame.as_bytes()[split_at..].to_vec(); + next_chunk.extend(second_frame.as_bytes()); + let parsed = parse_server_messages(ParseServerMessagesRequest { + buffer: partial.buffer, + chunk: next_chunk, + }) + .unwrap(); + assert_eq!(parsed.messages, vec![first.to_string(), second.to_string()]); + assert!(parsed.buffer.is_empty()); +} + +#[test] +fn client_core_shapes_feature_responses_for_swift_models() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() { la }\n".to_string(), + }) + .unwrap(); + let requested = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/completion".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 14, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let completed = client_apply_server_message(ClientApplyServerMessageRequest { + state: requested.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "items": [{ + "label": "launch", + "kind": 3, + "detail": "fn()", + "textEdit": { + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 14 } + }, + "newText": "launch" + } + }] + } + }"# + .to_string(), + }) + .unwrap(); + + let result = completed.events[0].result.as_ref().unwrap(); + assert_eq!(result["items"][0]["label"], "launch"); + assert_eq!( + result["items"][0]["textEdit"]["range"]["start"]["utf16Column"], + 12 + ); + + let rename = client_feature_request(ClientFeatureRequest { + state: completed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/rename".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 12, + }), + new_name: Some("start".to_string()), + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let renamed = client_apply_server_message(ClientApplyServerMessageRequest { + state: rename.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "2", + "result": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + } + }"# + .to_string(), + }) + .unwrap(); + let rename_result = renamed.events[0].result.as_ref().unwrap(); + assert_eq!( + rename_result["changes"]["/tmp/project/main.rs"][0]["newText"], + "start" + ); + + let formatting = client_feature_request(ClientFeatureRequest { + state: renamed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/formatting".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let formatted = client_apply_server_message(ClientApplyServerMessageRequest { + state: formatting.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "3", + "result": [{ + "range": { + "start": { "line": 0, "character": 2 }, + "end": { "line": 0, "character": 2 } + }, + "newText": " " + }] + }"# + .to_string(), + }) + .unwrap(); + let format_result = formatted.events[0].result.as_ref().unwrap(); + assert_eq!( + format_result["edits"][0]["range"]["start"]["utf16Column"], + 2 + ); + + let code_actions = client_feature_request(ClientFeatureRequest { + state: formatted.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/codeAction".to_string(), + position: None, + new_name: None, + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 0, + }, + }), + diagnostics: vec![LspClientDiagnostic { + range: LspRangeResponse { + start: LspPositionResponse { + line: 0, + utf16_column: 12, + }, + end: LspPositionResponse { + line: 0, + utf16_column: 18, + }, + }, + severity: Some(2), + message: "rename suggestion".to_string(), + source: Some("rust-analyzer".to_string()), + code: None, + }], + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); + assert_eq!(code_action_request["method"], "textDocument/codeAction"); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["range"]["start"]["character"], + 12 + ); + let code_actioned = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_actions.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "4", + "result": [{ + "title": "Apply rename", + "kind": "quickfix", + "isPreferred": true, + "edit": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + }, + "command": { + "title": "Apply", + "command": "rust-analyzer.applySourceChange", + "arguments": [{ "label": "rename" }] + }, + "data": { "id": "action-1" } + }] + }"# + .to_string(), + }) + .unwrap(); + let action_result = code_actioned.events[0].result.as_ref().unwrap(); + assert_eq!(action_result["actions"][0]["title"], "Apply rename"); + assert_eq!( + action_result["actions"][0]["edit"]["changes"]["/tmp/project/main.rs"][0]["newText"], + "start" + ); + assert_eq!( + action_result["actions"][0]["command"]["command"], + "rust-analyzer.applySourceChange" + ); + + let code_action_resolve = client_feature_request(ClientFeatureRequest { + state: code_actioned.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "codeAction/resolve".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: Some(json!({ + "title": "Apply rename", + "kind": "quickfix", + "isPreferred": true, + "data": { "id": "action-1" } + })), + command: None, + }) + .unwrap(); + let code_action_resolve_request: Value = + serde_json::from_str(&code_action_resolve.messages[0]).unwrap(); + assert_eq!(code_action_resolve_request["method"], "codeAction/resolve"); + assert_eq!( + code_action_resolve_request["params"]["title"], + "Apply rename" + ); + let code_action_resolved = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_action_resolve.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "5", + "result": { + "title": "Apply rename", + "kind": "quickfix", + "edit": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + }, + "data": { "id": "action-1" } + } + }"# + .to_string(), + }) + .unwrap(); + let code_action_resolve_result = code_action_resolved.events[0].result.as_ref().unwrap(); + assert_eq!( + code_action_resolve_result["action"]["edit"]["changes"]["/tmp/project/main.rs"][0] + ["newText"], + "start" + ); + + let completion_resolve = client_feature_request(ClientFeatureRequest { + state: code_action_resolved.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "completionItem/resolve".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: Some(json!({ + "label": "launch", + "insertText": "launch", + "kind": 3, + "textEdit": { + "range": { + "start": { "line": 0, "utf16Column": 12 }, + "end": { "line": 0, "utf16Column": 14 } + }, + "newText": "launch" + }, + "data": { "id": "completion-1" } + })), + code_action: None, + command: None, + }) + .unwrap(); + let completion_resolve_request: Value = + serde_json::from_str(&completion_resolve.messages[0]).unwrap(); + assert_eq!( + completion_resolve_request["method"], + "completionItem/resolve" + ); + assert_eq!( + completion_resolve_request["params"]["textEdit"]["range"]["start"]["character"], + 12 + ); + let completion_resolved = client_apply_server_message(ClientApplyServerMessageRequest { + state: completion_resolve.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "6", + "result": { + "label": "launch", + "kind": 3, + "detail": "fn launch()", + "documentation": { "kind": "markdown", "value": "Launches the app." }, + "insertText": "launch", + "data": { "id": "completion-1" } + } + }"# + .to_string(), + }) + .unwrap(); + let completion_resolve_result = completion_resolved.events[0].result.as_ref().unwrap(); + assert_eq!( + completion_resolve_result["item"]["documentation"], + "Launches the app." + ); + + let execute_command = client_feature_request(ClientFeatureRequest { + state: completion_resolved.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "workspace/executeCommand".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: Some(json!({ + "title": "Apply", + "command": "rust-analyzer.applySourceChange", + "arguments": [{ "label": "rename" }] + })), + }) + .unwrap(); + let execute_request: Value = serde_json::from_str(&execute_command.messages[0]).unwrap(); + assert_eq!(execute_request["method"], "workspace/executeCommand"); + assert_eq!( + execute_request["params"]["command"], + "rust-analyzer.applySourceChange" + ); + let executed = client_apply_server_message(ClientApplyServerMessageRequest { + state: execute_command.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "7", + "result": null + }"# + .to_string(), + }) + .unwrap(); + assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); +} + +#[test] +fn client_core_applies_diagnostics_and_dynamic_registrations() { + let state = LspClientState::default(); + let diagnostics = client_apply_server_message(ClientApplyServerMessageRequest { + state, + message: r#"{ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": "file:///tmp/project/main.py", + "diagnostics": [{ + "range": { + "start": { "line": 2, "character": 4 }, + "end": { "line": 2, "character": 9 } + }, + "severity": 1, + "source": "pyright", + "code": "reportGeneralTypeIssues", + "message": "Example diagnostic" + }] + } + }"# + .to_string(), + }) + .unwrap(); + let stored = diagnostics + .state + .diagnostics + .get("file:///tmp/project/main.py") + .unwrap(); + assert_eq!(stored[0].message, "Example diagnostic"); + assert_eq!(stored[0].range.start.utf16_column, 4); + assert_eq!(diagnostics.events[0].kind, "diagnostics"); + + let registered = client_apply_server_message(ClientApplyServerMessageRequest { + state: diagnostics.state, + message: r#"{ + "jsonrpc": "2.0", + "id": 77, + "method": "client/registerCapability", + "params": { + "registrations": [{ + "id": "formatting", + "method": "textDocument/formatting", + "registerOptions": {} + }] + } + }"# + .to_string(), + }) + .unwrap(); + assert!(registered + .state + .server_capabilities + .contains(&"formatting".to_string())); + let response: Value = serde_json::from_str(®istered.messages[0]).unwrap(); + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "id": 77, "result": null }) + ); + + let unregistered = client_apply_server_message(ClientApplyServerMessageRequest { + state: registered.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "unregister-1", + "method": "client/unregisterCapability", + "params": { + "unregisterations": [{ + "id": "formatting", + "method": "textDocument/formatting" + }] + } + }"# + .to_string(), + }) + .unwrap(); + assert!(!unregistered + .state + .server_capabilities + .contains(&"formatting".to_string())); + let response: Value = serde_json::from_str(&unregistered.messages[0]).unwrap(); + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "id": "unregister-1", "result": null }) + ); +} + +#[test] +fn client_core_answers_workspace_configuration_requests_by_item() { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: r#"{ + "jsonrpc": "2.0", + "id": "configuration-1", + "method": "workspace/configuration", + "params": { + "items": [ + { "section": "gopls" }, + { "scopeUri": "file:///tmp/project", "section": "gopls.ui" } + ] + } + }"# + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ + "jsonrpc": "2.0", + "id": "configuration-1", + "result": [null, null] + }) + ); +} + +#[test] +fn client_core_answers_workspace_folder_and_progress_requests() { + for (method, id) in [ + ("workspace/workspaceFolders", json!(42)), + ("window/workDoneProgress/create", json!("progress-1")), + ] { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": {} + }) + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ "jsonrpc": "2.0", "id": id, "result": null }) + ); + } +} + +#[test] +fn client_core_rejects_unknown_server_requests_with_method_not_found() { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: r#"{ + "jsonrpc": "2.0", + "id": 91, + "method": "experimental/notSupported", + "params": { "value": true } + }"# + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ + "jsonrpc": "2.0", + "id": 91, + "error": { + "code": -32601, + "message": "Method not found" + } + }) + ); +} diff --git a/rust/lithe-core/src/workspace.rs b/rust/lithe-core/src/project/files.rs similarity index 97% rename from rust/lithe-core/src/workspace.rs rename to rust/lithe-core/src/project/files.rs index 33b25a27..1282365a 100644 --- a/rust/lithe-core/src/workspace.rs +++ b/rust/lithe-core/src/project/files.rs @@ -1,5 +1,5 @@ -use crate::error::{invalid_relative_path, CoreError, ErrorCode}; -use crate::model::{ +use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; +use crate::protocol::{ FileReadResponse, FileWriteResponse, ReplacementPreviewResponse, SearchMatch, SearchResponse, WorkspaceNode, WorkspaceSnapshotResponse, }; @@ -146,7 +146,7 @@ pub fn search(request: SearchRequest) -> Result { let mut file_matches = 0; for path in &snapshot.files { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if matches.len() >= limit || file_matches >= file_limit { break; } @@ -167,7 +167,7 @@ pub fn search(request: SearchRequest) -> Result { let mut content_matches = 0; for path in &snapshot.files { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if matches.len() >= limit || content_matches >= content_limit { break; } @@ -190,7 +190,7 @@ pub fn search(request: SearchRequest) -> Result { Err(_) => continue, }; for (index, line) in text.split('\n').enumerate() { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if matcher.matches(line) { matches.push(SearchMatch { kind: "content".to_string(), @@ -233,7 +233,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result= symbol_limit && symbols.len() >= symbol_limit { break; } @@ -245,7 +245,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result 0 { - matches.push(crate::model::ReplacementMatch { + matches.push(crate::protocol::ReplacementMatch { line: index + 1, before: line.to_string(), after, @@ -336,7 +336,7 @@ pub fn replace_preview( } } if !matches.is_empty() { - files.push(crate::model::ReplacementFile { + files.push(crate::protocol::ReplacementFile { path: relative, matches, replacement_text: replaced_lines.join("\n"), @@ -427,7 +427,7 @@ fn scan_node( rules: &VisibilityRules, files: &mut Vec, ) -> Result { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; let metadata = fs::symlink_metadata(path)?; let relative = relative_path(path, root); let name = path @@ -481,7 +481,7 @@ fn scan_node( let mut visible_children = Vec::new(); for (child_path, _) in children { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if let Ok(child) = scan_node(&child_path, root, rules, files) { visible_children.push(child); } diff --git a/rust/lithe-core/src/history.rs b/rust/lithe-core/src/project/history.rs similarity index 99% rename from rust/lithe-core/src/history.rs rename to rust/lithe-core/src/project/history.rs index ad940437..b2fa348e 100644 --- a/rust/lithe-core/src/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -1,5 +1,5 @@ -use crate::error::{invalid_relative_path, CoreError, ErrorCode}; -use crate::model::{HistoryEntriesResponse, HistoryEntryResponse}; +use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; +use crate::protocol::{HistoryEntriesResponse, HistoryEntryResponse}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; diff --git a/rust/lithe-core/src/markdown.rs b/rust/lithe-core/src/project/markdown.rs similarity index 100% rename from rust/lithe-core/src/markdown.rs rename to rust/lithe-core/src/project/markdown.rs diff --git a/rust/lithe-core/src/maven.rs b/rust/lithe-core/src/project/maven.rs similarity index 99% rename from rust/lithe-core/src/maven.rs rename to rust/lithe-core/src/project/maven.rs index caf4a9f5..a87e6128 100644 --- a/rust/lithe-core/src/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -1,5 +1,5 @@ -use crate::error::{CoreError, ErrorCode}; -use crate::model::{ +use crate::protocol::{CoreError, ErrorCode}; +use crate::protocol::{ MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenModuleResponse, MavenProfileResponse, MavenScanResponse, }; diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs new file mode 100644 index 00000000..1063f6ec --- /dev/null +++ b/rust/lithe-core/src/project/mod.rs @@ -0,0 +1,11 @@ +//! Project files, search, local history, and document rendering services. + +mod files; +mod history; +mod markdown; +mod maven; + +pub(crate) use files::*; +pub(crate) use history::*; +pub(crate) use markdown::*; +pub(crate) use maven::*; diff --git a/rust/lithe-core/src/cancellation.rs b/rust/lithe-core/src/protocol/cancellation.rs similarity index 97% rename from rust/lithe-core/src/cancellation.rs rename to rust/lithe-core/src/protocol/cancellation.rs index 0e924e5b..174fbd05 100644 --- a/rust/lithe-core/src/cancellation.rs +++ b/rust/lithe-core/src/protocol/cancellation.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, ErrorCode}; +use crate::protocol::{CoreError, ErrorCode}; use std::cell::RefCell; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -95,7 +95,7 @@ fn registry() -> &'static Mutex>> { #[cfg(test)] mod tests { use super::{cancel, check, Scope}; - use crate::error::ErrorCode; + use crate::protocol::ErrorCode; use std::thread; use std::time::Duration; diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/protocol/command.rs similarity index 100% rename from rust/lithe-core/src/command.rs rename to rust/lithe-core/src/protocol/command.rs diff --git a/rust/lithe-core/src/model.rs b/rust/lithe-core/src/protocol/contracts.rs similarity index 99% rename from rust/lithe-core/src/model.rs rename to rust/lithe-core/src/protocol/contracts.rs index 39b567f6..ade97f94 100644 --- a/rust/lithe-core/src/model.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -1,4 +1,4 @@ -use crate::error::CoreError; +use crate::protocol::CoreError; use serde::Serialize; use serde_json::Value; diff --git a/rust/lithe-core/src/error.rs b/rust/lithe-core/src/protocol/error.rs similarity index 100% rename from rust/lithe-core/src/error.rs rename to rust/lithe-core/src/protocol/error.rs diff --git a/rust/lithe-core/src/event.rs b/rust/lithe-core/src/protocol/event.rs similarity index 74% rename from rust/lithe-core/src/event.rs rename to rust/lithe-core/src/protocol/event.rs index 959f1fca..3a8ca6e6 100644 --- a/rust/lithe-core/src/event.rs +++ b/rust/lithe-core/src/protocol/event.rs @@ -1,5 +1,5 @@ -use crate::error::CoreError; -use crate::model::{GitStatusResponse, SearchResponse, WorkspaceSnapshotResponse}; +use crate::protocol::CoreError; +use crate::protocol::{GitStatusResponse, SearchResponse, WorkspaceSnapshotResponse}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/protocol/mod.rs b/rust/lithe-core/src/protocol/mod.rs new file mode 100644 index 00000000..d060af01 --- /dev/null +++ b/rust/lithe-core/src/protocol/mod.rs @@ -0,0 +1,12 @@ +//! Stable command, response, error, event, and cancellation contracts. + +pub(crate) mod cancellation; +mod command; +mod contracts; +mod error; +mod event; + +pub use command::*; +pub use contracts::*; +pub use error::*; +pub use event::*; diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime/dispatcher.rs similarity index 91% rename from rust/lithe-core/src/runtime.rs rename to rust/lithe-core/src/runtime/dispatcher.rs index d07cce55..95756b88 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1,25 +1,24 @@ -use crate::command::{CoreCommand, CoreRequest}; -use crate::error::{CoreError, ErrorCode}; use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, GitPullPreflightRequest, GitStashesRequest, GitStatusRequest, GitWriteRequest, }; -use crate::history::{ - HistoryContentRequest, HistoryEntriesRequest, HistoryRecordRequest, HistoryRelocateRequest, -}; -use crate::java::{ +use crate::languages::{ JavaClassNameRequest, JavaCodeVisionRequest, JavaRunConfigurationsRequest, JavaServerPortRequest, JavaSourceDefinitionRequest, JavaStructureRequest, }; -use crate::markdown::MarkdownRenderRequest; -use crate::maven::{MavenDiagnosticsRequest, MavenScanRequest}; -use crate::model::CoreResponse; -use crate::workspace::{ +use crate::project::{ self, FileReadRequest, FileWriteRequest, ReplacementPreviewRequest, SearchRequest, WorkspaceSnapshotRequest, }; +use crate::project::{ + HistoryContentRequest, HistoryEntriesRequest, HistoryRecordRequest, HistoryRelocateRequest, +}; +use crate::project::{MarkdownRenderRequest, MavenDiagnosticsRequest, MavenScanRequest}; +use crate::protocol::CoreResponse; +use crate::protocol::{CoreCommand, CoreRequest}; +use crate::protocol::{CoreError, ErrorCode}; use serde_json::json; pub fn execute_json(request: &str) -> String { @@ -48,8 +47,8 @@ fn execute(request: &str) -> CoreResponse { let response_id = id.clone(); let operation_id = parsed.operation_id.clone().or_else(|| id.clone()); let _cancellation_scope = - crate::cancellation::Scope::begin(operation_id, parsed.timeout_milliseconds); - if let Err(error) = crate::cancellation::check() { + crate::protocol::cancellation::Scope::begin(operation_id, parsed.timeout_milliseconds); + if let Err(error) = crate::protocol::cancellation::check() { return CoreResponse::failure(id, error); } let Some(command) = CoreCommand::parse(&parsed.command) else { @@ -77,7 +76,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(workspace::snapshot) + .and_then(project::snapshot) { Ok(data) => CoreResponse::success( id, @@ -92,7 +91,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid search request") .with_details(error.to_string()) }) - .and_then(workspace::search) + .and_then(project::search) { Ok(data) => CoreResponse::success( id, @@ -110,7 +109,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(workspace::search_everywhere) + .and_then(project::search_everywhere) { Ok(data) => CoreResponse::success( id, @@ -128,7 +127,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(workspace::replace_preview) + .and_then(project::replace_preview) { Ok(data) => CoreResponse::success( id, @@ -142,7 +141,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid file read request") .with_details(error.to_string()) }) - .and_then(workspace::read_file) + .and_then(project::read_file) { Ok(data) => CoreResponse::success( id, @@ -155,7 +154,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid file write request") .with_details(error.to_string()) }) - .and_then(workspace::write_file) + .and_then(project::write_file) { Ok(data) => CoreResponse::success( id, @@ -169,7 +168,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid history record request") .with_details(error.to_string()) }) - .and_then(crate::history::record) + .and_then(crate::project::record) { Ok(data) => CoreResponse::success( id, @@ -184,7 +183,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid history entries request") .with_details(error.to_string()) }) - .and_then(crate::history::entries) + .and_then(crate::project::entries) { Ok(data) => CoreResponse::success( id, @@ -199,7 +198,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid history content request") .with_details(error.to_string()) }) - .and_then(crate::history::content) + .and_then(crate::project::content) { Ok(data) => CoreResponse::success(id, serde_json::json!({"text": data})), Err(error) => CoreResponse::failure(id, error), @@ -214,7 +213,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::history::relocate) + .and_then(crate::project::relocate) { Ok(()) => CoreResponse::success(id, serde_json::json!({"relocated": true})), Err(error) => CoreResponse::failure(id, error), @@ -225,7 +224,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid Maven scan request") .with_details(error.to_string()) }) - .and_then(crate::maven::scan) + .and_then(crate::project::scan) { Ok(data) => CoreResponse::success( id, @@ -242,7 +241,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::maven::diagnostics) + .and_then(crate::project::diagnostics) { Ok(data) => CoreResponse::success( id, @@ -258,7 +257,7 @@ fn execute(request: &str) -> CoreResponse { }) { Ok(request) => CoreResponse::success( id, - serde_json::to_value(crate::markdown::render(request)) + serde_json::to_value(crate::project::render(request)) .expect("Markdown render response should encode"), ), Err(error) => CoreResponse::failure(id, error), @@ -459,14 +458,12 @@ fn execute(request: &str) -> CoreResponse { } } CoreCommand::LspSessionExecute => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP session request") - .with_details(error.to_string()) - }) - .and_then(crate::lsp_host::execute) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP session request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::session_execute) { Ok(data) => CoreResponse::success( id, @@ -514,7 +511,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::run_configurations) + .and_then(crate::languages::run_configurations) { Ok(data) => CoreResponse::success( id, @@ -525,7 +522,7 @@ fn execute(request: &str) -> CoreResponse { } } CoreCommand::RunConfigInspect => { - match serde_json::from_value::(parsed.payload) + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -533,31 +530,29 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::run_configuration::inspect) + .and_then(crate::execution::inspect) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigGenerate => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid run configuration generate request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::run_configuration::generate) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration generate request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::generate) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigResolve => { - match serde_json::from_value::(parsed.payload) + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -565,49 +560,48 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::run_configuration::resolve) + .and_then(crate::execution::resolve) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigUpdateOptions => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid run options request") - .with_details(error.to_string()) - }) - .and_then(crate::run_configuration::update_options) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid run options request") + .with_details(error.to_string()) + }) + .and_then(crate::execution::update_options) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigCreateUserConfiguration => { - match serde_json::from_value::< - crate::run_configuration::CreateUserConfigurationRequest, - >(parsed.payload) + match serde_json::from_value::( + parsed.payload, + ) .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid user configuration request") - .with_details(error.to_string()) + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid user configuration request", + ) + .with_details(error.to_string()) }) - .and_then(crate::run_configuration::create_user_configuration) + .and_then(crate::execution::create_user_configuration) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigCreateLaunchPlan => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid launch plan request") - .with_details(error.to_string()) - }) - .and_then(crate::run_configuration::create_launch_plan) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid launch plan request") + .with_details(error.to_string()) + }) + .and_then(crate::execution::create_launch_plan) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), @@ -622,7 +616,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::code_vision) + .and_then(crate::languages::code_vision) { Ok(data) => CoreResponse::success( id, @@ -637,7 +631,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid Java class name request") .with_details(error.to_string()) }) - .and_then(crate::java::class_name) + .and_then(crate::languages::class_name) { Ok(data) => CoreResponse::success( id, @@ -655,7 +649,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::source_definition) + .and_then(crate::languages::source_definition) { Ok(data) => CoreResponse::success( id, @@ -673,7 +667,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::server_port) + .and_then(crate::languages::server_port) { Ok(data) => CoreResponse::success( id, @@ -688,7 +682,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid Java structure request") .with_details(error.to_string()) }) - .and_then(crate::java::structure) + .and_then(crate::languages::structure) { Ok(data) => CoreResponse::success( id, @@ -949,7 +943,7 @@ fn execute(request: &str) -> CoreResponse { }, }; if response.is_success() { - match crate::cancellation::check() { + match crate::protocol::cancellation::check() { Ok(()) => response, Err(error) => CoreResponse::failure(response_id, error), } diff --git a/rust/lithe-core/src/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs similarity index 96% rename from rust/lithe-core/src/ffi.rs rename to rust/lithe-core/src/runtime/ffi.rs index 62a0a979..2f17e3bf 100644 --- a/rust/lithe-core/src/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -44,7 +44,7 @@ pub unsafe extern "C" fn lithe_core_cancel(operation_id: *const c_char) -> i32 { return 0; } let operation_id = CStr::from_ptr(operation_id).to_string_lossy(); - crate::cancellation::cancel(&operation_id) as i32 + crate::protocol::cancellation::cancel(&operation_id) as i32 } #[no_mangle] diff --git a/rust/lithe-core/src/runtime/mod.rs b/rust/lithe-core/src/runtime/mod.rs new file mode 100644 index 00000000..a15e9e75 --- /dev/null +++ b/rust/lithe-core/src/runtime/mod.rs @@ -0,0 +1,6 @@ +//! JSON command dispatch and the exported C ABI. + +mod dispatcher; +mod ffi; + +pub(crate) use dispatcher::execute_json; diff --git a/rust/lithe-core/src/tests/detectors.rs b/rust/lithe-core/src/tests/detectors.rs new file mode 100644 index 00000000..c0fd80c1 --- /dev/null +++ b/rust/lithe-core/src/tests/detectors.rs @@ -0,0 +1,343 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; +use std::path::Path; + +/// Builds a project that mixes six ecosystems in one tree, including the +/// traps that break naive detectors: a lockfile that is not npm's, a +/// `node_modules` full of decoy manifests, and a Go command in a +/// subdirectory with no manifest of its own. +fn multi_language_project(root: &Path) { + for directory in ["frontend/node_modules/decoy", "api", "cmd/gateway"] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + fs::write( + root.join("frontend/package.json"), + r#"{"name":"web","scripts":{"dev":"vite","build":"vite build"}}"#, + ) + .unwrap(); + fs::write(root.join("frontend/pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); + fs::write( + root.join("frontend/node_modules/decoy/package.json"), + r#"{"scripts":{"dev":"should-never-appear"}}"#, + ) + .unwrap(); + fs::write( + root.join("api/pyproject.toml"), + "[tool.poetry]\nname = \"api\"\n[tool.poetry.scripts]\napi-server = \"api.main:run\"\n", + ) + .unwrap(); + fs::write( + root.join("api/main.py"), + "from fastapi import FastAPI\napp = FastAPI()\n", + ) + .unwrap(); + fs::write(root.join("go.mod"), "module example.com/gw\ngo 1.22\n").unwrap(); + fs::write( + root.join("cmd/gateway/main.go"), + "package main\nfunc main() {}\n", + ) + .unwrap(); + fs::write( + root.join("docker-compose.yml"), + "services:\n db:\n image: postgres\n cache:\n image: redis\n", + ) + .unwrap(); + fs::write( + root.join("Procfile"), + "worker: python worker/run.py\nweb: gunicorn api.main:app\n", + ) + .unwrap(); + fs::write( + root.join("Makefile"), + "run:\n\techo run\nclean:\n\techo clean\n", + ) + .unwrap(); +} + +fn generated_configurations(root: &Path) -> Vec { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate", + "command": "runConfig.generate", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["generated"]["configurations"] + .as_array() + .cloned() + .unwrap() +} + +/// The headline behaviour: one project, six ecosystems, every service found +/// without the user configuring anything. +#[test] +fn detectors_find_services_across_unrelated_ecosystems() { + let root = temporary_root("detect-multi"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + + let ids = generated_configurations(&root) + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect::>(); + + for expected in [ + "npm.script:frontend/dev", + "python.script:api/api-server", + "python.uvicorn:api/main", + "go.command:cmd/gateway/gateway", + "compose.service:db", + "compose.stack:compose up", + "procfile.process:web", + "make.target:run", + ] { + assert!( + ids.contains(&expected.to_string()), + "missing {expected} in {ids:?}" + ); + } + + fs::remove_dir_all(root).unwrap(); +} + +/// A dependency tree contains thousands of manifests. Descending into it +/// would both bury the real services and make project open unusably slow. +#[test] +fn detectors_never_descend_into_dependency_directories() { + let root = temporary_root("detect-prune"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + + let sources = generated_configurations(&root) + .iter() + .filter_map(|item| item["source"].as_str().map(str::to_string)) + .collect::>(); + + assert!( + !sources.iter().any(|source| source.contains("node_modules")), + "{sources:?}" + ); + + fs::remove_dir_all(root).unwrap(); +} + +/// Running `npm run dev` in a pnpm workspace fails at spawn time with an +/// error that points nowhere useful, so the lockfile decides the command. +#[test] +fn npm_detector_uses_the_package_manager_the_lockfile_names() { + let root = temporary_root("detect-pnpm"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + + let dev = generated_configurations(&root) + .into_iter() + .find(|item| item["id"] == "npm.script:frontend/dev") + .unwrap(); + + assert_eq!(dev["command"], "pnpm"); + assert_eq!(dev["args"], serde_json::json!(["run", "dev"])); + assert_eq!(dev["cwd"], "frontend"); + assert_eq!(dev["execution"], "service"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn npm_detector_inherits_the_workspace_package_manager() { + let root = temporary_root("detect-pnpm-workspace"); + fs::create_dir_all(root.join("apps/web")).unwrap(); + fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); + fs::write( + root.join("apps/web/package.json"), + r#"{"scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + + let dev = generated_configurations(&root) + .into_iter() + .find(|item| item["id"] == "npm.script:apps/web/dev") + .unwrap(); + assert_eq!(dev["command"], "pnpm"); + assert_eq!(dev["execution"], "service"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn detectors_preserve_application_service_and_task_semantics() { + let root = temporary_root("detect-execution-semantics"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + let configurations = generated_configurations(&root); + let execution = |id: &str| { + configurations + .iter() + .find(|item| item["id"] == id) + .and_then(|item| item["execution"].as_str()) + }; + + assert_eq!(execution("npm.script:frontend/dev"), Some("service")); + assert_eq!(execution("npm.script:frontend/build"), Some("task")); + assert_eq!( + execution("python.script:api/api-server"), + Some("application") + ); + assert_eq!( + execution("go.command:cmd/gateway/gateway"), + Some("application") + ); + assert_eq!(execution("compose.stack:compose up"), Some("service")); + + fs::remove_dir_all(root).unwrap(); +} + +/// Detected entries are process-based, so they must survive the same launch +/// path as any other configuration without acquiring Java assumptions. +#[test] +fn detected_services_produce_runnable_launch_plans() { + let root = temporary_root("detect-launch"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + let generated = serde_json::json!({ + "version": 2, + "configurations": generated_configurations(&root) + }); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&generated).unwrap(), + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "npm.script:frontend/dev"} + }) + .to_string(), + )) + .unwrap(); + + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["command"], "pnpm"); + assert!(plan["data"]["executable"]["toolchain"].is_null()); + assert_eq!(plan["data"]["workingDirectory"], "frontend"); + assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +/// Ids are the join key for the team and local override layers. A detector +/// that renamed a Java configuration would silently detach every override +/// written against it, with no error anywhere. +#[test] +fn detectors_never_claim_an_id_the_java_scan_already_produced() { + let root = temporary_root("detect-no-clobber"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("src/Main.java"), + "class Main { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-with-java", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/Main.java"]} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let ids = response["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect::>(); + let mut unique = ids.clone(); + unique.sort(); + unique.dedup(); + + assert_eq!(ids.len(), unique.len(), "duplicate ids in {ids:?}"); + assert!(ids.contains(&"current-file".to_string())); + + fs::remove_dir_all(root).unwrap(); +} + +/// A Java project that also ships a frontend must gain the frontend's +/// services without any Java configuration changing id. Ids are the join key +/// for the team and local layers, so a shifted id detaches every override +/// silently -- there is no error to notice. +#[test] +fn detectors_extend_a_java_project_without_disturbing_its_configurations() { + let root = temporary_root("detect-java-mixed"); + let module = root.join("backend-api/src/main/java/com/demo"); + fs::create_dir_all(&module).unwrap(); + fs::create_dir_all(root.join("frontend-web")).unwrap(); + fs::write( + root.join("pom.xml"), + "backend-api", + ) + .unwrap(); + fs::write(root.join("backend-api/pom.xml"), "").unwrap(); + fs::write( + module.join("BackendApplication.java"), + "package com.demo;\n@SpringBootApplication\npublic class BackendApplication { public static void main(String[] a) {} }\n", + ) + .unwrap(); + fs::write( + root.join("frontend-web/package.json"), + r#"{"name":"web","scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": ["backend-api/src/main/java/com/demo/BackendApplication.java"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + let ids = configurations + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect::>(); + + assert!( + ids.contains(&"spring:com.demo.BackendApplication".to_string()), + "{ids:?}" + ); + assert!( + ids.contains(&"npm.script:frontend-web/dev".to_string()), + "{ids:?}" + ); + // The Java entries stay toolchain-backed; only the detected ones are + // process-based. A regression here would send npm through Maven. + let java = configurations + .iter() + .find(|item| item["id"] == "spring:com.demo.BackendApplication") + .unwrap(); + assert!(java["command"].is_null()); + assert_eq!(java["toolchains"]["maven"], "project-maven"); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs new file mode 100644 index 00000000..e654b812 --- /dev/null +++ b/rust/lithe-core/src/tests/git.rs @@ -0,0 +1,1309 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; +use std::process::Command; + +#[test] +fn git_status_returns_contract_shape() { + let root = temporary_root("git"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + fs::write(root.join("new.txt"), "new").expect("test file should be writable"); + + let request = serde_json::json!({ + "id": "git", + "command": "git.status", + "payload": {"root": root} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Git request should encode"), + )) + .expect("Git response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["repositoryRoot"], "."); + assert_eq!(response["data"]["changes"][0]["path"], "new.txt"); + assert_eq!(response["data"]["changes"][0]["untracked"], true); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_command_returns_combined_output_and_exit_code() { + let root = temporary_root("git-command"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + + let request = serde_json::json!({ + "id": "git-command", + "command": "git.command", + "payload": { + "root": root, + "arguments": ["--version"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Git command request should encode"), + )) + .expect("Git command response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["exitCode"], 0); + assert!(response["data"]["output"] + .as_str() + .expect("Git version output should be text") + .contains("git version")); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_validates_and_executes_shared_mutations() { + let root = temporary_root("git-write"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + let stage = request("stage", serde_json::json!({"paths": ["example.txt"]})); + assert_eq!(stage["ok"], true); + let commit = request( + "commit", + serde_json::json!({"message": "initial", "amend": false}), + ); + assert_eq!(commit["ok"], true); + + fs::write(root.join("example.txt"), "staged change\n").expect("file should be writable"); + assert_eq!( + request("stage", serde_json::json!({"paths": ["example.txt"]}))["ok"], + true + ); + assert_eq!( + request("unstage", serde_json::json!({"paths": ["example.txt"]}))["ok"], + true + ); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + " M example.txt\n" + ); + assert_eq!( + request("discard", serde_json::json!({"paths": ["example.txt"]}))["ok"], + true + ); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "initial\n" + ); + + // Conflict-dialog rollback must discard both sides of a file, including + // a staged edit followed by a working-tree edit. + fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); + let discard_all = request("discardAll", serde_json::json!({"paths": ["example.txt"]})); + assert_eq!(discard_all["ok"], true, "{discard_all:?}"); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "initial\n" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "" + ); + + fs::write(root.join("untracked.txt"), "discard me\n") + .expect("untracked file should be writable"); + assert_eq!( + request("discard", serde_json::json!({"paths": ["untracked.txt"]}))["ok"], + true + ); + assert!(!root.join("untracked.txt").exists()); + + let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) + .trim() + .to_string(); + let create = request( + "createBranch", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "name": "feature/core", + "checkout": true + }), + ); + assert_eq!(create["ok"], true); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/core" + ); + + let checkout = request( + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "referenceKind": "local" + }), + ); + assert_eq!(checkout["ok"], true); + assert_eq!(checkout["data"]["exitCode"], 0, "{checkout:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + + // Nested branch names go through the same short-name path. + assert!(run(&["branch", "feature/nested"]).status.success()); + let nested = request( + "checkout", + serde_json::json!({ + "reference": "refs/heads/feature/nested", + "referenceKind": "local" + }), + ); + assert_eq!(nested["ok"], true); + assert_eq!(nested["data"]["exitCode"], 0, "{nested:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/nested" + ); + assert!(run(&["switch", ¤t]).status.success()); + + fs::write(root.join("example.txt"), "working tree\n").expect("file should be writable"); + let stash = request( + "stashPush", + serde_json::json!({"message": "core write", "includeUntracked": false}), + ); + assert_eq!(stash["ok"], true); + let pop = request("stashPop", serde_json::json!({"reference": "stash@{0}"})); + assert_eq!(pop["ok"], true); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "working tree\n" + ); + + // Checkout conflict handling. `feature/core` and the current branch hold different + // content for conflict.txt, so a dirty working copy of it blocks a plain switch. + fs::write(root.join("conflict.txt"), "on main\n").expect("file should be writable"); + assert!(run(&["add", "conflict.txt"]).status.success()); + assert!(run(&["commit", "-qm", "main conflict"]).status.success()); + assert!(run(&["switch", "feature/core"]).status.success()); + fs::write(root.join("conflict.txt"), "on feature\n").expect("file should be writable"); + assert!(run(&["add", "conflict.txt"]).status.success()); + assert!(run(&["commit", "-qm", "feature conflict"]).status.success()); + assert!(run(&["switch", ¤t]).status.success()); + + let preflight = |reference: &str| -> Value { + let value = execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "preflight", + "command": "git.checkoutPreflight", + "payload": {"root": root, "reference": reference} + })) + .expect("request should encode"), + ); + serde_json::from_str(&value).expect("response should decode") + }; + + // Clean tree: nothing blocks the switch. + let clean = preflight("refs/heads/feature/core"); + assert_eq!(clean["ok"], true, "{clean:?}"); + assert_eq!( + clean["data"]["blockingPaths"], + serde_json::json!([]), + "{clean:?}" + ); + + // Dirty and divergent: preflight names the exact blocking file. + fs::write(root.join("conflict.txt"), "local edit\n").expect("file should be writable"); + let blocked = preflight("refs/heads/feature/core"); + assert_eq!(blocked["ok"], true); + assert_eq!( + blocked["data"]["blockingPaths"], + serde_json::json!(["conflict.txt"]), + "{blocked:?}" + ); + + // Untracked files that the target branch tracks also block a checkout, even + // though they never appear in `git diff HEAD`. + assert!(run(&["stash", "-u"]).status.success()); + fs::write(root.join("conflict.txt"), "untracked local\n").expect("file should be writable"); + let untracked_block = preflight("refs/heads/feature/core"); + assert_eq!( + untracked_block["data"]["blockingPaths"], + serde_json::json!(["conflict.txt"]), + "{untracked_block:?}" + ); + fs::remove_file(root.join("conflict.txt")).expect("file should be removable"); + assert!(run(&["stash", "pop"]).status.success()); + + // A plain checkout is refused rather than clobbering the edit. + let refused = request( + "checkout", + serde_json::json!({ + "reference": "refs/heads/feature/core", + "referenceKind": "local" + }), + ); + assert_ne!(refused["data"]["exitCode"], 0, "{refused:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + + // Smart checkout stashes the edit, switches, and restores it. + assert!(run(&["switch", "-c", "feature/smart"]).status.success()); + let smart = request( + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "referenceKind": "local", + "autoStash": true + }), + ); + assert_eq!(smart["ok"], true); + assert_eq!(smart["data"]["exitCode"], 0, "{smart:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + assert_eq!( + fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), + "local edit\n" + ); + assert!( + String::from_utf8_lossy(&run(&["stash", "list"]).stdout).is_empty(), + "smart checkout should consume its stash" + ); + + // Force checkout discards the local edit and lands on the target branch. + let forced = request( + "checkout", + serde_json::json!({ + "reference": "refs/heads/feature/core", + "referenceKind": "local", + "force": true + }), + ); + assert_eq!(forced["ok"], true); + assert_eq!(forced["data"]["exitCode"], 0, "{forced:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/core" + ); + assert_eq!( + fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), + "on feature\n" + ); + assert!(run(&["switch", ¤t]).status.success()); + + let clone = root + .parent() + .expect("temporary root should have a parent") + .join(format!("lithe-core-clone-{}", std::process::id())); + let clone_result = request( + "clone", + serde_json::json!({ + "remote": root.to_string_lossy(), + "destination": clone.to_string_lossy() + }), + ); + assert_eq!(clone_result["ok"], true); + assert!(clone.join(".git").exists()); + fs::remove_dir_all(clone).expect("temporary clone should be removable"); + + let invalid = request( + "reset", + serde_json::json!({"revision": "HEAD", "mode": "--invalid"}), + ); + assert_eq!(invalid["ok"], false); + assert_eq!(invalid["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn stash_restore_conflicts_return_structured_recovery_data() { + let root = temporary_root("git-stash-conflict"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("shared.txt"), "feature\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature edit"]).status.success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + + fs::write(root.join("shared.txt"), "local\n").expect("file should be writable"); + assert!(run(&["stash", "push", "-qm", "restore conflict"]) + .status + .success()); + let stash_reference = String::from_utf8_lossy(&run(&["stash", "list", "--format=%gd"]).stdout) + .lines() + .next() + .expect("stash reference should exist") + .trim() + .to_string(); + assert!(run(&["switch", "-q", "feature"]).status.success()); + + let write = |operation: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": format!("stash-{operation}"), + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "reference": stash_reference + } + })) + .expect("stash request should encode"), + )) + .expect("stash response should be JSON") + }; + + let applied = write("stashApply"); + assert_eq!(applied["ok"], true, "{applied:?}"); + assert_eq!(applied["data"]["exitCode"], 1, "{applied:?}"); + assert_eq!( + applied["data"]["stashRestore"]["stashReference"], stash_reference, + "{applied:?}" + ); + assert_eq!( + applied["data"]["stashRestore"]["conflictedPaths"], + serde_json::json!(["shared.txt"]), + "{applied:?}" + ); + + // Clear the index conflict without dropping the saved entry, then verify + // `pop` reports the same structured recovery data. + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + let popped = write("stashPop"); + assert_eq!(popped["ok"], true, "{popped:?}"); + assert_eq!(popped["data"]["exitCode"], 1, "{popped:?}"); + assert_eq!( + popped["data"]["stashRestore"]["stashReference"], stash_reference, + "{popped:?}" + ); + assert_eq!( + popped["data"]["stashRestore"]["conflictedPaths"], + serde_json::json!(["shared.txt"]), + "{popped:?}" + ); + + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + assert!(run(&["stash", "drop", &stash_reference]).status.success()); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_operation_state_reports_and_resolves_a_merge_conflict() { + let root = temporary_root("git-operation"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) + .trim() + .to_string(); + + let state = || -> Value { + let value = execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "operation-state", + "command": "git.operationState", + "payload": {"root": root} + })) + .expect("request should encode"), + ); + serde_json::from_str(&value).expect("response should decode") + }; + let write = |operation: &str| -> Value { + let value = execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "operation-write", + "command": "git.write", + "payload": {"root": root, "operation": operation} + })) + .expect("request should encode"), + ); + serde_json::from_str(&value).expect("response should decode") + }; + + // A settled repository reports no operation and no conflicts. + let idle = state(); + assert_eq!(idle["ok"], true, "{idle:?}"); + assert_eq!(idle["data"]["kind"], "", "{idle:?}"); + assert_eq!(idle["data"]["conflictedPaths"], serde_json::json!([])); + + // Continuing when nothing is in progress is rejected rather than run blindly. + let nothing = write("operationContinue"); + assert_eq!(nothing["ok"], false, "{nothing:?}"); + assert_eq!(nothing["error"]["code"], "invalid_request"); + + // Build two branches that edit the same line, so merging must conflict. + assert!(run(&["switch", "-qc", "feature/conflict"]).status.success()); + fs::write(root.join("shared.txt"), "from feature\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature edit"]).status.success()); + assert!(run(&["switch", "-q", ¤t]).status.success()); + fs::write(root.join("shared.txt"), "from main\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "main edit"]).status.success()); + + // Conflicting merges exit non-zero; the point is the state they leave behind. + assert!(!run(&["merge", "--no-edit", "feature/conflict"]) + .status + .success()); + + let conflicted = state(); + assert_eq!(conflicted["ok"], true, "{conflicted:?}"); + assert_eq!(conflicted["data"]["kind"], "merge", "{conflicted:?}"); + assert_eq!( + conflicted["data"]["conflictedPaths"], + serde_json::json!(["shared.txt"]), + "{conflicted:?}" + ); + + // Continuing with the conflict unresolved is refused, so the user cannot + // commit conflict markers by clicking through the banner. + let premature = write("operationContinue"); + assert_eq!(premature["ok"], false, "{premature:?}"); + assert_eq!(premature["error"]["code"], "invalid_request"); + + // A merge has no skip step. + let skip = write("operationSkip"); + assert_eq!(skip["ok"], false, "{skip:?}"); + + // Resolving the file and continuing completes the merge without opening an editor. + fs::write(root.join("shared.txt"), "resolved\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + let finished = write("operationContinue"); + assert_eq!(finished["ok"], true, "{finished:?}"); + assert_eq!(finished["data"]["exitCode"], 0, "{finished:?}"); + + let settled = state(); + assert_eq!(settled["data"]["kind"], "", "{settled:?}"); + assert_eq!(settled["data"]["conflictedPaths"], serde_json::json!([])); + + // Abort restores the pre-merge state of a fresh conflict. + fs::write(root.join("shared.txt"), "main again\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "main again"]).status.success()); + assert!(run(&["switch", "-q", "feature/conflict"]).status.success()); + fs::write(root.join("shared.txt"), "feature again\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature again"]).status.success()); + assert!(!run(&["merge", "--no-edit", ¤t]).status.success()); + assert_eq!(state()["data"]["kind"], "merge"); + + let aborted = write("operationAbort"); + assert_eq!(aborted["ok"], true, "{aborted:?}"); + assert_eq!(aborted["data"]["exitCode"], 0, "{aborted:?}"); + assert_eq!(state()["data"]["kind"], ""); + assert_eq!( + fs::read_to_string(root.join("shared.txt")).expect("file should be readable"), + "feature again\n" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_diff_and_apply_round_trip_a_patch() { + let root = temporary_root("git-diff"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "before\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + fs::write(root.join("example.txt"), "after\n").expect("file should be writable"); + + let diff = serde_json::json!({ + "id": "diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"], + "contextLines": 80 + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&diff).expect("diff request should encode"), + )) + .expect("diff response should be JSON"); + assert_eq!(response["ok"], true); + assert!(response["data"]["patch"] + .as_str() + .expect("diff output should be text") + .contains("+after")); + assert_eq!(response["data"]["hunks"].as_array().unwrap().len(), 1); + assert!(response["data"]["rows"] + .as_array() + .unwrap() + .iter() + .any(|row| row["kind"] == "changed" && row["right"] == "after")); + + let reference_diff = serde_json::json!({ + "id": "reference-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"], + "reference": "HEAD", + "contextLines": 80 + } + }); + let reference_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&reference_diff).expect("reference diff should encode"), + )) + .expect("reference diff response should be JSON"); + assert_eq!(reference_response["ok"], true); + assert!(reference_response["data"]["patch"] + .as_str() + .expect("reference diff patch should be text") + .contains("+after")); + + let apply = serde_json::json!({ + "id": "apply", + "command": "git.apply", + "payload": { + "root": root, + "patch": response["data"]["patch"], + "mode": "stage" + } + }); + let apply_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&apply).expect("apply request should encode"), + )) + .expect("apply response should be JSON"); + assert_eq!(apply_response["ok"], true); + assert_eq!(apply_response["data"]["exitCode"], 0); + + let status = run(&["status", "--porcelain"]).stdout; + assert_eq!(String::from_utf8_lossy(&status), "M example.txt\n"); + + // Shelve restores the index snapshot and the unstaged worktree delta + // separately. Verify that a file with both kinds of edits returns as MM + // and keeps the final worktree content. + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + let staged_diff = serde_json::json!({ + "id": "staged-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"], + "staged": true + } + }); + let staged_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&staged_diff).expect("staged diff request should encode"), + )) + .expect("staged diff response should be JSON"); + let staged_patch = staged_response["data"]["patch"] + .as_str() + .expect("staged patch should be text") + .to_string(); + + fs::write(root.join("example.txt"), "final\n").expect("file should be writable"); + let working_diff = serde_json::json!({ + "id": "working-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"] + } + }); + let working_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&working_diff).expect("working diff request should encode"), + )) + .expect("working diff response should be JSON"); + let working_patch = working_response["data"]["patch"] + .as_str() + .expect("working patch should be text") + .to_string(); + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + + for (id, patch, mode) in [ + ("restore-index", staged_patch.as_str(), "restoreIndex"), + ("restore-worktree", working_patch.as_str(), "worktree"), + ] { + let apply = serde_json::json!({ + "id": id, + "command": "git.apply", + "payload": {"root": root, "patch": patch, "mode": mode} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&apply).expect("restore apply request should encode"), + )) + .expect("restore apply response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + } + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "MM example.txt\n" + ); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "final\n" + ); + + for (id, patch, mode) in [ + ( + "restore-index-check", + staged_patch.as_str(), + "restoreIndexCheck", + ), + ("worktree-check", working_patch.as_str(), "worktreeCheck"), + ] { + let check = serde_json::json!({ + "id": id, + "command": "git.apply", + "payload": {"root": root, "patch": patch, "mode": mode} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&check).expect("patch check request should encode"), + )) + .expect("patch check response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + } + + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + fs::write(root.join("new.txt"), "untracked\n").expect("file should be writable"); + let untracked_diff = serde_json::json!({ + "id": "untracked-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["new.txt"], + "untracked": true + } + }); + let untracked_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&untracked_diff).expect("untracked diff request should encode"), + )) + .expect("untracked diff response should be JSON"); + let untracked_patch = untracked_response["data"]["patch"] + .as_str() + .expect("untracked patch should be text") + .to_string(); + fs::remove_file(root.join("new.txt")).expect("file should be removable"); + let untracked_apply = serde_json::json!({ + "id": "untracked-apply", + "command": "git.apply", + "payload": {"root": root, "patch": untracked_patch, "mode": "worktree"} + }); + let untracked_apply_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&untracked_apply).expect("untracked apply request should encode"), + )) + .expect("untracked apply response should be JSON"); + assert_eq!(untracked_apply_response["ok"], true); + assert_eq!(untracked_apply_response["data"]["exitCode"], 0); + assert_eq!( + fs::read_to_string(root.join("new.txt")).expect("file should be readable"), + "untracked\n" + ); + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_history_returns_references_and_commit_graph_fields() { + let root = temporary_root("git-history"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "hello\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let blame_request = serde_json::json!({ + "id": "blame", + "command": "git.blame", + "payload": {"root": root, "path": "example.txt"} + }); + let blame_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&blame_request).expect("blame request should encode"), + )) + .expect("blame response should be JSON"); + assert_eq!(blame_response["ok"], true); + assert_eq!(blame_response["data"]["lines"][0]["line"], 1); + assert_eq!( + blame_response["data"]["lines"][0]["commitHash"], + commit_hash + ); + + let commit_request = serde_json::json!({ + "id": "commit", + "command": "git.commit", + "payload": {"root": root, "commit": commit_hash} + }); + let commit_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&commit_request).expect("commit request should encode"), + )) + .expect("commit response should be JSON"); + assert_eq!(commit_response["ok"], true); + assert_eq!(commit_response["data"]["commit"]["hash"], commit_hash); + + let files_request = serde_json::json!({ + "id": "files", + "command": "git.commitFiles", + "payload": {"root": root, "commit": commit_hash} + }); + let files_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&files_request).expect("commit files request should encode"), + )) + .expect("commit files response should be JSON"); + assert_eq!(files_response["ok"], true); + assert_eq!(files_response["data"]["files"][0]["path"], "example.txt"); + + fs::write(root.join("example.txt"), "changed\n").expect("file should be writable"); + let comparison_request = serde_json::json!({ + "id": "comparison", + "command": "git.comparison", + "payload": {"root": root, "reference": "HEAD"} + }); + let comparison_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&comparison_request).expect("comparison request should encode"), + )) + .expect("comparison response should be JSON"); + assert_eq!(comparison_response["ok"], true); + assert_eq!( + comparison_response["data"]["files"][0]["path"], + "example.txt" + ); + + assert!(run(&["stash", "push", "-qm", "saved"]).status.success()); + let stashes_request = serde_json::json!({ + "id": "stashes", + "command": "git.stashes", + "payload": {"root": root} + }); + let stashes_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&stashes_request).expect("stashes request should encode"), + )) + .expect("stashes response should be JSON"); + assert_eq!(stashes_response["ok"], true); + assert_eq!(stashes_response["data"]["stashes"][0]["message"], "saved"); + + let request = serde_json::json!({ + "id": "history", + "command": "git.history", + "payload": {"root": root, "reference": "HEAD", "limit": 10} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["commits"][0]["subject"], "initial"); + assert!( + response["data"]["commits"][0]["hash"] + .as_str() + .expect("commit hash should be text") + .len() + >= 7 + ); + assert!(response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .any(|reference| reference["kind"] == "local")); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} +#[test] +fn git_conflict_markers_ignore_markdown_headings() { + let root = temporary_root("git-markers"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + let markers = || -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "conflict-markers", + "command": "git.conflictMarkers", + "payload": {"root": root} + })) + .expect("request should encode"), + )) + .expect("conflict marker response should be JSON") + }; + + // A Markdown setext heading underline looks exactly like the middle of a + // conflict block, so matching a bare `=======` would flag ordinary docs. + fs::write(root.join("doc.md"), "Title\n=======\n\nbody\n").expect("file should be writable"); + assert!(run(&["add", "doc.md"]).status.success()); + let clean = markers(); + assert_eq!(clean["ok"], true); + assert_eq!( + clean["data"]["paths"].as_array().unwrap().len(), + 0, + "a Markdown heading is not a conflict: {clean}" + ); + + // Only files carrying the opening or closing marker are real conflicts. + fs::write( + root.join("code.txt"), + "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\n", + ) + .expect("file should be writable"); + // The diff3 style adds a `|||||||` base section, which also counts. + fs::write( + root.join("diff3.txt"), + "x\n<<<<<<< HEAD\na\n||||||| base\nb\n=======\nc\n>>>>>>> other\n", + ) + .expect("file should be writable"); + assert!(run(&["add", "."]).status.success()); + + let found = markers(); + let paths = found["data"]["paths"].as_array().unwrap(); + assert_eq!(paths.len(), 2, "{found}"); + assert_eq!(paths[0], "code.txt"); + assert_eq!(paths[1], "diff3.txt"); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_integration_preflight_separates_merge_overlap_from_rebase_strictness() { + let root = temporary_root("git-integration"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); + assert!(run(&["add", "."]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + // A side branch that only ever touches shared.txt. + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "incoming"]).status.success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + // Move main forward so the branches genuinely diverge. + fs::write(root.join("main.txt"), "main\n").expect("file should be writable"); + assert!(run(&["add", "main.txt"]).status.success()); + assert!(run(&["commit", "-qm", "main side"]).status.success()); + + let preflight = |operation: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "integration-preflight", + "command": "git.integrationPreflight", + "payload": { + "root": root, + "reference": "refs/heads/feature", + "operation": operation + } + })) + .expect("request should encode"), + )) + .expect("integration preflight response should be JSON") + }; + + // A clean tree blocks neither operation. + assert_eq!( + preflight("merge")["data"]["blockingPaths"] + .as_array() + .unwrap() + .len(), + 0 + ); + assert_eq!( + preflight("rebase")["data"]["blockingPaths"] + .as_array() + .unwrap() + .len(), + 0 + ); + + // Dirty a file the incoming branch never touches. Git lets a merge proceed + // here but still refuses a rebase, so the two must report differently. + fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); + + let merge = preflight("merge"); + assert_eq!(merge["ok"], true); + assert_eq!( + merge["data"]["blockingPaths"].as_array().unwrap().len(), + 0, + "an unrelated edit should not block a merge: {merge}" + ); + assert_eq!(merge["data"]["blocksEntirely"], false); + + let rebase = preflight("rebase"); + assert_eq!(rebase["data"]["blockingPaths"][0], "other.txt"); + assert_eq!(rebase["data"]["blocksEntirely"], true); + + // Now dirty the file the merge would write; that one does block it. + fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); + let overlapping = preflight("merge"); + assert_eq!(overlapping["data"]["blockingPaths"][0], "shared.txt"); + assert_eq!( + overlapping["data"]["blockingPaths"] + .as_array() + .unwrap() + .len(), + 1, + "only the overlapping file blocks: {overlapping}" + ); + + // An unknown operation is rejected rather than guessed at. + let invalid = serde_json::from_str::(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "integration-preflight", + "command": "git.integrationPreflight", + "payload": { + "root": root, + "reference": "refs/heads/feature", + "operation": "graft" + } + })) + .expect("request should encode"), + )) + .expect("response should be JSON"); + assert_eq!(invalid["ok"], false); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_integration_preflight_scopes_cherry_pick_to_the_replayed_commit() { + let root = temporary_root("git-cherry-pick"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); + assert!(run(&["add", "."]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + // A side branch of two commits. Only the second one touches shared.txt, so + // picking it must consider that file alone rather than the whole branch. + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("early.txt"), "early\n").expect("file should be writable"); + assert!(run(&["add", "early.txt"]).status.success()); + assert!(run(&["commit", "-qm", "earlier work"]).status.success()); + fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "touches shared"]).status.success()); + let pick = + String::from_utf8(run(&["rev-parse", "HEAD"]).stdout).expect("a revision should be UTF-8"); + let pick = pick.trim().to_string(); + assert!(run(&["switch", "-q", "main"]).status.success()); + + let preflight = |operation: &str, reference: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "integration-preflight", + "command": "git.integrationPreflight", + "payload": { + "root": root, + "reference": reference, + "operation": operation + } + })) + .expect("request should encode"), + )) + .expect("integration preflight response should be JSON") + }; + + // An edit to a file the picked commit never touches is not in its way, the + // same rule a merge follows and unlike a rebase. + fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); + for operation in ["cherryPick", "revert"] { + let clear = preflight(operation, &pick); + assert_eq!(clear["ok"], true, "{operation} should succeed: {clear}"); + assert_eq!( + clear["data"]["blockingPaths"].as_array().unwrap().len(), + 0, + "an unrelated edit should not block {operation}: {clear}" + ); + assert_eq!(clear["data"]["blocksEntirely"], false); + } + + // Dirtying the file that commit rewrites does block it. + fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); + let blocked = preflight("cherryPick", &pick); + assert_eq!(blocked["data"]["blockingPaths"][0], "shared.txt"); + assert_eq!( + blocked["data"]["blockingPaths"].as_array().unwrap().len(), + 1, + "only the file the commit writes blocks it: {blocked}" + ); + + // The branch tip as a whole also adds early.txt, but picking the single + // commit must not inherit that; a merge of the same ref would report it. + let merge = preflight("merge", "refs/heads/feature"); + let merge_blocking = merge["data"]["blockingPaths"].as_array().unwrap(); + assert!( + merge_blocking.iter().any(|path| path == "shared.txt"), + "the merge shares the overlap: {merge}" + ); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { + let root = temporary_root("git-pull"); + let upstream = root.join("upstream"); + let work = root.join("work"); + fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); + + let git = |directory: &std::path::Path, arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") + }; + let identify = |directory: &std::path::Path| { + assert!(git(directory, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + git(directory, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(git(directory, &["config", "user.name", "Lithe Test"]) + .status + .success()); + }; + + assert!(git(&upstream, &["init", "-q", "-b", "main"]) + .status + .success()); + identify(&upstream); + fs::write(upstream.join("shared.txt"), "base\n").expect("file should be writable"); + assert!(git(&upstream, &["add", "shared.txt"]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "initial"]) + .status + .success()); + + assert!(git( + &root, + &[ + "clone", + "-q", + "-c", + "core.autocrlf=false", + "upstream", + "work" + ] + ) + .status + .success()); + identify(&work); + + let preflight = || -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "pull-preflight", + "command": "git.pullPreflight", + "payload": {"root": work} + })) + .expect("request should encode"), + )) + .expect("pull preflight response should be JSON") + }; + + // A fresh clone is level with its upstream, so nothing needs deciding. + let clean = preflight(); + assert_eq!(clean["ok"], true); + assert_eq!(clean["data"]["upstream"], "origin/main"); + assert_eq!(clean["data"]["diverged"], false); + assert_eq!(clean["data"]["ahead"], 0); + assert_eq!(clean["data"]["behind"], 0); + + // Commit on both sides so neither can fast-forward past the other. + fs::write(upstream.join("remote.txt"), "remote\n").expect("file should be writable"); + assert!(git(&upstream, &["add", "remote.txt"]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "remote"]) + .status + .success()); + fs::write(work.join("local.txt"), "local\n").expect("file should be writable"); + assert!(git(&work, &["add", "local.txt"]).status.success()); + assert!(git(&work, &["commit", "-qm", "local"]).status.success()); + assert!(git(&work, &["fetch", "-q"]).status.success()); + + let diverged = preflight(); + assert_eq!(diverged["data"]["diverged"], true); + assert_eq!(diverged["data"]["ahead"], 1); + assert_eq!(diverged["data"]["behind"], 1); + + let pull = |mode: Option<&str>| -> Value { + let mut payload = serde_json::json!({"root": work, "operation": "pull"}); + if let Some(mode) = mode { + payload["mode"] = serde_json::json!(mode); + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "pull", + "command": "git.write", + "payload": payload + })) + .expect("request should encode"), + )) + .expect("pull response should be JSON") + }; + + // The default refuses a divergent history rather than inventing a merge. + let refused = pull(None); + assert_ne!(refused["data"]["exitCode"], 0); + + // Rebase replays the local commit on top, leaving a linear history. + let rebased = pull(Some("rebase")); + assert_eq!(rebased["data"]["exitCode"], 0, "{rebased}"); + + let settled = preflight(); + assert_eq!(settled["data"]["diverged"], false); + assert_eq!(settled["data"]["behind"], 0); + assert_eq!(settled["data"]["ahead"], 1); + + // An unknown strategy is rejected before Git ever runs. + let invalid = pull(Some("squash")); + assert_eq!(invalid["ok"], false); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs new file mode 100644 index 00000000..94db4b73 --- /dev/null +++ b/rust/lithe-core/src/tests/languages.rs @@ -0,0 +1,198 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; + +#[test] +fn maven_scan_returns_recursive_shared_project_model() { + let root = temporary_root("maven"); + fs::create_dir_all(root.join("module-a/module-b")).expect("modules should be creatable"); + fs::write( + root.join("pom.xml"), + r#"com.exampledemo1pommodule-adevtrue"#, + ) + .expect("root pom should be writable"); + fs::write( + root.join("module-a/pom.xml"), + r#"onemodule-b"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("module-a/module-b/pom.xml"), + r#"two"#, + ) + .expect("nested pom should be writable"); + fs::write(root.join("mvnw.cmd"), "@echo off\n").expect("wrapper should be writable"); + + let request = serde_json::json!({ + "id": "maven", + "command": "maven.scan", + "payload": {"root": root} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["artifactId"], "demo"); + assert_eq!(response["data"]["packaging"], "pom"); + assert_eq!(response["data"]["profiles"][0]["id"], "dev"); + assert_eq!(response["data"]["hasWrapper"], true); + assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); + assert_eq!( + response["data"]["modules"][0]["modules"][0]["relativePath"], + "module-a/module-b" + ); + let diagnostics = serde_json::json!({ + "id": "maven-diagnostics", + "command": "maven.diagnostics", + "payload": { + "root": root, + "output": "[ERROR] src/App.java:[12,4] cannot find symbol\n[ERROR] src/App.java:[12,4] cannot find symbol\n[WARNING] src/App.java:[4] unused import\n" + } + }); + let diagnostics_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&diagnostics).expect("diagnostics request should encode"), + )) + .expect("diagnostics response should be JSON"); + assert_eq!(diagnostics_response["ok"], true); + assert_eq!( + diagnostics_response["data"]["issues"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + diagnostics_response["data"]["issues"][0]["severity"], + "error" + ); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn java_core_commands_return_shared_runtime_and_structure_data() { + let root = temporary_root("java"); + fs::create_dir_all(root.join("src/main/java/com/example")) + .expect("Java source should be creatable"); + fs::write( + root.join("src/main/java/com/example/App.java"), + "package com.example;\n@SpringBootApplication\nclass App {\n static void main(String[] args) {}\n}\n", + ) + .expect("Java source should be writable"); + let configurations = serde_json::json!({ + "id": "java-config", + "command": "java.runConfigurations", + "payload": { + "root": root, + "paths": ["src/main/java/com/example/App.java"], + "modulePaths": ["src"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&configurations).expect("Java request should encode"), + )) + .expect("Java response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!( + response["data"]["mainClasses"][0]["qualifiedName"], + "com.example.App" + ); + assert_eq!(response["data"]["configurations"][0]["kind"], "springBoot"); + assert_eq!(response["data"]["configurations"][0]["modulePath"], "src"); + + let structure = serde_json::json!({ + "id": "java-structure", + "command": "java.structure", + "payload": { + "source": "import a.A;\nimport b.B;\ninterface Service {}\n" + } + }); + let structure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&structure).expect("Java structure request should encode"), + )) + .expect("Java structure response should be JSON"); + assert_eq!(structure_response["ok"], true); + assert_eq!( + structure_response["data"]["foldRegions"][0]["kind"], + "imports" + ); + assert_eq!( + structure_response["data"]["implementationMarkers"][0]["direction"], + "down" + ); + let swift_structure = serde_json::json!({ + "id": "swift-structure", + "command": "java.structure", + "payload": { + "source": "struct Demo {\n func run() {\n if ready {\n work()\n }\n }\n}\n" + } + }); + let swift_structure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&swift_structure).expect("Swift structure request should encode"), + )) + .expect("Swift structure response should be JSON"); + let swift_folds = swift_structure_response["data"]["foldRegions"] + .as_array() + .expect("Swift structure should return fold regions"); + assert!(swift_folds + .iter() + .any(|fold| { fold["startLine"] == 0 && fold["endLine"] == 6 && fold["kind"] == "type" })); + assert!(swift_folds.iter().any(|fold| { + fold["startLine"] == 1 && fold["endLine"] == 5 && fold["kind"] == "method" + })); + let code_vision = serde_json::json!({ + "id": "java-vision", + "command": "java.codeVision", + "payload": { + "root": root, + "targetPath": "src/main/java/com/example/App.java", + "paths": ["src/main/java/com/example/App.java"] + } + }); + let vision_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&code_vision).expect("code vision request should encode"), + )) + .expect("code vision response should be JSON"); + assert_eq!(vision_response["ok"], true); + assert!(vision_response["data"]["hints"] + .as_array() + .unwrap() + .iter() + .any(|hint| hint["symbol"] == "App")); + let class_name = serde_json::json!({ + "id": "java-class", + "command": "java.className", + "payload": {"source": "package com.example;\nclass App {}", "simpleName": "App"} + }); + let class_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&class_name).expect("class name request should encode"), + )) + .expect("class name response should be JSON"); + assert_eq!(class_response["data"]["className"], "com.example.App"); + let definition = serde_json::json!({ + "id": "java-definition", + "command": "java.sourceDefinition", + "payload": { + "source": "class App {\n void run() {}\n}", + "declarationName": "App", + "memberName": "run" + } + }); + let definition_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&definition).expect("definition request should encode"), + )) + .expect("definition response should be JSON"); + assert_eq!(definition_response["data"]["line"], 1); + let server_port = serde_json::json!({ + "id": "java-port", + "command": "java.serverPort", + "payload": {"content": "server:\n port: 8080\n", "fileExtension": "yml"} + }); + let port_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&server_port).expect("server port request should encode"), + )) + .expect("server port response should be JSON"); + assert_eq!(port_response["data"]["port"], 8080); + fs::remove_dir_all(root).expect("Java fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs new file mode 100644 index 00000000..67722294 --- /dev/null +++ b/rust/lithe-core/src/tests/mod.rs @@ -0,0 +1,7 @@ +mod detectors; +mod git; +mod languages; +mod project; +mod protocol; +mod run_configuration; +mod support; diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs new file mode 100644 index 00000000..d01ed5b7 --- /dev/null +++ b/rust/lithe-core/src/tests/project.rs @@ -0,0 +1,468 @@ +use super::support::{fixture, temporary_root}; +use crate::execute_json; +use serde_json::Value; +use std::fs; + +#[test] +fn markdown_render_command_returns_sanitized_html() { + let request = serde_json::json!({ + "id": "markdown-1", + "command": "markdown.render", + "payload": { + "source": "# Preview\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n```plantuml\nAlice -> Bob\n```\n\n" + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Markdown request should encode"), + )) + .expect("Markdown response should be JSON"); + + assert_eq!(response["id"], "markdown-1"); + assert_eq!(response["ok"], true); + let html = response["data"]["html"] + .as_str() + .expect("Markdown response should contain HTML"); + assert!(html.contains(" Vec { + let request = serde_json::json!({ + "id": "mask", + "command": "workspace.search", + "payload": { + "root": root, + "query": "total", + "fileMask": mask + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("search request should encode"), + )) + .expect("search response should be JSON"); + assert_eq!(response["ok"], true); + response["data"]["matches"] + .as_array() + .expect("matches should be an array") + .iter() + .map(|value| value["path"].as_str().unwrap_or_default().to_string()) + .collect() + }; + + let unfiltered = search(""); + assert!(unfiltered.iter().any(|path| path.ends_with("Service.java"))); + assert!(unfiltered.iter().any(|path| path.ends_with("notes.txt"))); + + let java_only = search("*.java"); + assert!(java_only.iter().any(|path| path.ends_with("Service.java"))); + assert!(!java_only.iter().any(|path| path.ends_with("notes.txt"))); + + // 多个掩码取并集,且容忍逗号后的空格。 + let both = search("*.java, *.txt"); + assert!(both.iter().any(|path| path.ends_with("Service.java"))); + assert!(both.iter().any(|path| path.ends_with("notes.txt"))); + + fs::remove_dir_all(root).expect("temporary fixture should be removable"); +} + +#[test] +fn preserve_case_matches_original_occurrence_shape() { + let root = temporary_root("preserve-case"); + fs::create_dir_all(&root).expect("fixture directory should be creatable"); + let relative = "Sample.java"; + fs::write(root.join(relative), "fooBar FooBar FOOBAR fooBar();\n") + .expect("fixture should be writable"); + + let replace = |preserve_case: bool| -> String { + let request = serde_json::json!({ + "id": "preserve", + "command": "workspace.replacePreview", + "payload": { + "root": root, + "query": "fooBar", + "replacement": "bazQux", + "caseSensitive": false, + "preserveCase": preserve_case, + "paths": [relative] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("replace request should encode"), + )) + .expect("replace response should be JSON"); + assert_eq!(response["ok"], true); + response["data"]["files"][0]["matches"][0]["after"] + .as_str() + .expect("after text should be a string") + .to_string() + }; + + assert_eq!(replace(false), "bazQux bazQux bazQux bazQux();"); + assert_eq!(replace(true), "bazQux BazQux BAZQUX bazQux();"); + + fs::remove_dir_all(root).expect("temporary fixture should be removable"); +} + +#[test] +fn local_history_records_deduplicates_lists_and_relocates() { + let root = temporary_root("history"); + fs::create_dir_all(&root).expect("history workspace should be creatable"); + let storage = root.join("history-storage"); + + let request = |command: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": command, + "command": command, + "payload": payload + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON") + }; + + let record_payload = |content: &str| { + serde_json::json!({ + "workspaceRoot": root, + "storageRoot": storage, + "path": "src/Main.java", + "reason": "saved", + "content": content, + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [] + }) + }; + let first = request("history.record", record_payload("one\n")); + assert_eq!(first["ok"], true); + assert!(first["data"]["id"].as_str().is_some()); + let duplicate = request("history.record", record_payload("one\n")); + assert_eq!(duplicate["ok"], true); + assert!(duplicate["data"].is_null()); + let mut invalid_reason = record_payload("invalid\n"); + invalid_reason["reason"] = serde_json::json!("not-a-history-reason"); + let invalid = request("history.record", invalid_reason); + assert_eq!(invalid["ok"], false); + assert_eq!(invalid["error"]["code"], "invalid_request"); + + let second = request("history.record", record_payload("two\n")); + assert_eq!(second["ok"], true); + let listed = request( + "history.entries", + serde_json::json!({ + "workspaceRoot": root, + "storageRoot": storage, + "path": "src/Main.java" + }), + ); + assert_eq!(listed["ok"], true); + assert_eq!(listed["data"]["entries"].as_array().unwrap().len(), 2); + let content_path = listed["data"]["entries"][0]["contentPath"] + .as_str() + .unwrap(); + let content = request( + "history.content", + serde_json::json!({ + "storageRoot": storage, + "contentPath": content_path + }), + ); + assert_eq!(content["data"]["text"], "two\n"); + + let relocated = request( + "history.relocate", + serde_json::json!({ + "storageRoot": storage, + "sourcePath": "src/Main.java", + "destinationPath": "src/Renamed.java" + }), + ); + assert_eq!(relocated["ok"], true); + let relocated_entries = request( + "history.entries", + serde_json::json!({ + "workspaceRoot": root, + "storageRoot": storage, + "path": "src/Renamed.java" + }), + ); + assert_eq!( + relocated_entries["data"]["entries"] + .as_array() + .unwrap() + .len(), + 2 + ); + + let traversal = request( + "history.content", + serde_json::json!({ + "storageRoot": storage, + "contentPath": "../outside.snapshot" + }), + ); + assert_eq!(traversal["ok"], false); + assert_eq!(traversal["error"]["code"], "invalid_request"); + fs::remove_dir_all(root).expect("history workspace should be removable"); +} + +#[test] +fn file_commands_round_trip_and_reject_traversal() { + let root = temporary_root("file"); + let outside = temporary_root("outside"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + fs::create_dir_all(&outside).expect("outside directory should be creatable"); + + let write = serde_json::json!({ + "id": "write", + "command": "file.write", + "payload": {"root": root, "path": "nested/example.txt", "text": "hello"} + }); + let write_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&write).expect("write request should encode"), + )) + .expect("write response should be JSON"); + assert_eq!(write_response["ok"], true); + + let read = serde_json::json!({ + "id": "read", + "command": "file.read", + "payload": {"root": root, "path": "nested/example.txt"} + }); + let read_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&read).expect("read request should encode"), + )) + .expect("read response should be JSON"); + assert_eq!(read_response["data"]["text"], "hello"); + + let traversal = serde_json::json!({ + "id": "traversal", + "command": "file.read", + "payload": {"root": root, "path": "../outside.txt"} + }); + let traversal_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&traversal).expect("traversal request should encode"), + )) + .expect("traversal response should be JSON"); + assert_eq!(traversal_response["ok"], false); + assert_eq!(traversal_response["error"]["code"], "invalid_request"); + + for path in [ + "..\\outside.txt", + "nested\\..\\outside.txt", + "C:\\outside.txt", + ] { + let request = serde_json::json!({ + "id": "windows-path", + "command": "file.read", + "payload": {"root": root, "path": path} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Windows path request should encode"), + )) + .expect("Windows path response should be JSON"); + assert_eq!(response["ok"], false, "path {path} should be rejected"); + assert_eq!(response["error"]["code"], "invalid_request"); + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, root.join("link")) + .expect("test symlink should be creatable"); + let symlink_write = serde_json::json!({ + "id": "symlink-write", + "command": "file.write", + "payload": {"root": root, "path": "link/escape.txt", "text": "outside"} + }); + let symlink_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&symlink_write).expect("symlink request should encode"), + )) + .expect("symlink response should be JSON"); + assert_eq!(symlink_response["ok"], false); + assert_eq!(symlink_response["error"]["code"], "permission_denied"); + assert!(!outside.join("escape.txt").exists()); + } + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); + fs::remove_dir_all(outside).expect("outside fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs new file mode 100644 index 00000000..fcd09f39 --- /dev/null +++ b/rust/lithe-core/src/tests/protocol.rs @@ -0,0 +1,14 @@ +use crate::execute_json; +use serde_json::Value; + +#[test] +fn ping_exposes_protocol_version() { + let response: Value = serde_json::from_str(&execute_json( + r#"{"id":"test-1","command":"core.ping","payload":{}}"#, + )) + .expect("ping response should be JSON"); + + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["protocolVersion"], 1); + assert_eq!(response["data"]["coreVersion"], "0.1.0"); +} diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs new file mode 100644 index 00000000..fde8fe2e --- /dev/null +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -0,0 +1,1031 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; +use std::path::PathBuf; + +#[test] +fn run_configuration_commands_generate_merge_and_plan() { + let root = temporary_root("run-config"); + fs::create_dir_all(root.join("src/main/java/com/example")) + .expect("source directory should be creatable"); + fs::write(root.join("src/main/java/com/example/App.java"), "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }").expect("source should be writable"); + fs::write(root.join("pom.xml"), "21").expect("pom should be writable"); + + let request = serde_json::json!({"id":"generate","command":"runConfig.generate","payload":{"root":root,"paths":["src/main/java/com/example/App.java"],"modulePaths":[]}}); + let generated: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("generate response should be JSON"); + assert_eq!(generated["ok"], true); + assert_eq!(generated["data"]["generated"]["version"], 2); + assert!(generated["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .any(|v| v["id"] == "current-file")); + + let generated_doc = serde_json::to_string(&generated["data"]["generated"]).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join(".lithe/run/generated.json"), generated_doc).unwrap(); + fs::write(root.join(".lithe/run/configurations.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"My File","type":"java.current-file","workingDirectory":"backend","jvmArguments":["-Xmx2g"],"toolchains":{"maven":"custom-maven"}}]}"#).unwrap(); + fs::write(root.join(".lithe/run/local.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"Local File","type":"java.current-file","workingDirectory":".","programArguments":["--dev"],"toolchains":{"java":"custom-jdk"}}]}"#).unwrap(); + + let resolve: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"resolve","command":"runConfig.resolve","payload":{"root":root}}) + .to_string(), + )) + .unwrap(); + assert_eq!(resolve["ok"], true); + let current = resolve["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|v| v["id"] == "current-file") + .unwrap(); + assert_eq!(current["name"], "Local File"); + assert_eq!(current["toolchains"]["java"], "custom-jdk"); + assert_eq!(current["toolchains"]["maven"], "custom-maven"); + let plan: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"plan","command":"runConfig.createLaunchPlan","payload":{"root":root,"configurationId":"current-file","currentFile":"src/main/java/com/example/App.java"}}).to_string())).unwrap(); + assert_eq!(plan["ok"], true); + assert_eq!(plan["data"]["executable"]["toolchain"], "custom-jdk"); + let debug_plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"debug-plan", + "command":"runConfig.createLaunchPlan", + "payload":{ + "root":root, + "configurationId":"spring:com.example.App", + "debugPort":5005 + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(debug_plan["ok"], true); + assert!(debug_plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .any(|argument| argument.contains("address=127.0.0.1:5005"))); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_generation_infers_maven_modules_from_nearest_pom() { + let root = temporary_root("run-config-inferred-modules"); + let backend = "backend-api/src/main/java/com/example/BackendApplication.java"; + let worker = "batch-worker/src/main/java/com/example/WorkerMain.java"; + fs::create_dir_all(root.join("backend-api/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("backend-api/pom.xml"), "").unwrap(); + fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); + fs::write( + root.join(backend), + "package com.example; @SpringBootApplication class BackendApplication { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(worker), + "package com.example; class WorkerMain { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-inferred-modules", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": [backend, worker], + "modulePaths": [] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + assert!(configurations.iter().any(|value| { + value["id"] == "spring:com.example.BackendApplication" + && value["extensions"]["maven"]["module"] == "backend-api" + })); + assert!(configurations.iter().any(|value| { + value["id"] == "java-main:com.example.WorkerMain" + && value["extensions"]["maven"]["module"] == "batch-worker" + && value["execution"] == "application" + })); + assert!(!configurations + .iter() + .any(|value| value["provider"] == "maven.module")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn ordinary_java_main_uses_an_application_launch_plan() { + let root = temporary_root("run-config-java-main"); + let source = "batch-worker/src/main/java/com/example/WorkerMain.java"; + fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); + fs::write( + root.join(source), + "package com.example; class WorkerMain { public static void main(String[] args) {} }", + ) + .unwrap(); + + let generated_response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-java-main", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [source], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated = &generated_response["data"]["generated"]; + let java_main = generated["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["provider"] == "java.main") + .unwrap(); + assert_eq!(java_main["execution"], "application"); + assert_eq!(java_main["extensions"]["maven"]["module"], "batch-worker"); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(generated).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-java-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:com.example.WorkerMain" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); + assert!(plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "-Dexec.mainClass=com.example.WorkerMain")); + assert_eq!( + plan["data"]["arguments"] + .as_array() + .unwrap() + .last() + .unwrap(), + "org.codehaus.mojo:exec-maven-plugin:3.5.0:java" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { + let root = temporary_root("run-config-errors"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join(".lithe/run/generated.json"), "{").unwrap(); + + let inspect = |id: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": id, + "command": "runConfig.inspect", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap() + }; + let malformed = inspect("malformed"); + assert_eq!(malformed["ok"], false); + assert_eq!(malformed["error"]["code"], "parse_failed"); + + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":3,"configurations":[]}"#, + ) + .unwrap(); + let unsupported = inspect("unsupported"); + assert_eq!(unsupported["ok"], false); + assert_eq!(unsupported["error"]["code"], "not_supported"); + assert!(unsupported["error"]["details"] + .as_str() + .unwrap() + .contains("found 3")); + + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[]}"#, + ) + .unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write(root.join(".lithe/toolchains/local.json"), "{").unwrap(); + let malformed_toolchains = inspect("malformed-toolchains"); + assert_eq!(malformed_toolchains["ok"], false); + assert_eq!(malformed_toolchains["error"]["code"], "parse_failed"); + assert!(malformed_toolchains["error"]["message"] + .as_str() + .unwrap() + .contains(".lithe/toolchains/local.json")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_mutations_are_shared_and_validated() { + let root = temporary_root("run-config-mutations"); + fs::create_dir_all(root.join("src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join("src/main/java/com/example/App.java"), + "package com.example; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file","toolchains":{"java":"project-jdk"}}]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-options", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "current-file", + "workingDirectory": ".", + "jvmArguments": "\"-Dlabel=hello world\" -Xmx2g", + "programArguments": "--dev", + "mavenProfiles": ["dev"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true); + let updated_document: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!( + updated_document["configurations"][0]["extensions"]["maven"]["jvmArguments"], + serde_json::json!(["-Dlabel=hello world", "-Xmx2g"]) + ); + fs::write( + root.join(".lithe/run/configurations.json"), + updated["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + + let create = |name: &str, module: &str, main_class: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "create-user", + "command": "runConfig.createUserConfiguration", + "payload": { + "root": root, + "scope": "project", + "name": name, + "type": "springBoot", + "module": module, + "mainClass": main_class + } + }) + .to_string(), + )) + .unwrap() + }; + let first = create("Backend Dev", ".", "com.example.App"); + assert_eq!(first["data"]["id"], "user:backend-dev"); + fs::write( + root.join(".lithe/run/configurations.json"), + first["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + let second = create("Backend Dev", ".", "com.example.App"); + assert_eq!(second["data"]["id"], "user:backend-dev-2"); + assert_eq!( + create("Outside", "../outside", "com.example.App")["ok"], + false + ); + assert_eq!(create("Missing", ".", "com.example.Missing")["ok"], false); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_generation_detects_declared_toolchain_versions() { + let root = temporary_root("run-config-toolchains"); + fs::create_dir_all(root.join(".mvn/wrapper")).unwrap(); + fs::write(root.join(".sdkmanrc"), "java=21.0.5-tem\n").unwrap(); + fs::write(root.join("mvnw"), "#!/bin/sh\n").unwrap(); + fs::write( + root.join(".mvn/wrapper/maven-wrapper.properties"), + "distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip\n", + ) + .unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-toolchains", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], + "21" + ); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["preferredVendor"], + "temurin" + ); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"], + "3.9.9" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_generation_detects_maven_compiler_target() { + let root = temporary_root("run-config-compiler-target"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("pom.xml"), + "17", + ) + .unwrap(); + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-target", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], + "17" + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_inspection_summarizes_changed_inputs() { + let root = temporary_root("run-config-input-summary"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/App.java"), "class App {}").unwrap(); + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-summary", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated_again: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-summary-again", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["data"], generated_again["data"]); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&generated["data"]["generated"]).unwrap(), + ) + .unwrap(); + fs::write(root.join("src/App.java"), "class App { int changed; }").unwrap(); + + let inspected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "inspect-summary", + "command": "runConfig.inspect", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(inspected["ok"], true); + assert_eq!( + inspected["data"]["diagnostics"][0]["message"], + "Project inputs changed: 0 added, 0 removed, 1 modified" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_resolve_matches_toolchains_and_rejects_unsafe_paths() { + let root = temporary_root("run-config-toolchain-resolution"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + r#"{"version":1,"toolchains":{"project-jdk":{"type":"java","minimumVersion":"21","preferredVendor":"temurin"}}}"#, + ) + .unwrap(); + + let resolve = |version: &str, vendor: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-toolchains", + "command": "runConfig.resolve", + "payload": { + "root": root, + "toolchainCandidates": [{ + "id": "project-jdk", + "type": "java", + "version": version, + "vendor": vendor + }] + } + }) + .to_string(), + )) + .unwrap() + }; + let mismatch = resolve("17.0.12", "Zulu"); + assert!(mismatch["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "toolchainVersionMismatch")); + assert!(mismatch["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "toolchainVendorMismatch")); + + let matching = resolve("21.0.5", "Eclipse Temurin"); + assert!(matching["data"]["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":1,"configurations":[{"id":"current-file","workingDirectory":"../outside"}]}"#, + ) + .unwrap(); + let unsafe_path = resolve("21.0.5", "Eclipse Temurin"); + assert_eq!(unsafe_path["ok"], false); + assert_eq!(unsafe_path["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_main_class_validation_uses_the_declared_package() { + let root = temporary_root("run-config-main-class-package"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("src/main/java/other")).unwrap(); + fs::write( + root.join("src/main/java/other/App.java"), + "package other; class App {}", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"spring:com.example.App","name":"App","type":"spring-boot.maven","mainClass":"com.example.App"}]}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-main-class", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "missingMainClass")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { + let directory = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../shared/fixtures/run-configuration"); + let mut fixture_count = 0; + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + fixture_count += 1; + let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(value["version"], 1, "{}", path.display()); + assert!(value["expected"].is_object(), "{}", path.display()); + if let Some(generated) = value.get("generated") { + assert!(generated["version"].is_number(), "{}", path.display()); + assert!(generated["configurations"].is_array(), "{}", path.display()); + } + } + assert!(fixture_count >= 6); +} + +#[test] +fn run_configuration_resolve_diagnoses_orphans_and_deleted_modules() { + let root = temporary_root("run-config-diagnostics"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"},{"id":"module:deleted","name":"Deleted","type":"maven.module","module":"deleted"}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":1,"configurations":[{"id":"module:old","jvmArguments":["-Xmx1g"]}]}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-diagnostics", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "orphanedOverride")); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "missingModule")); + + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":1,"configurations":[{"id":"module:deleted","jvmArguments":["-Xmx1g"]}]}"#, + ) + .unwrap(); + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-missing-module", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true); + assert_eq!( + resolved["data"]["configurations"].as_array().unwrap().len(), + 1 + ); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "missingModule")); + + fs::remove_dir_all(root).unwrap(); +} + +/// The v1 -> v2 rewrite must not change a single byte of the emitted command +/// line. Values are asserted literally rather than recomputed, so a future +/// refactor that silently drops an argument fails here instead of at runtime. +#[test] +fn migrated_v1_documents_produce_identical_launch_arguments() { + let root = temporary_root("run-config-migration"); + fs::create_dir_all(root.join("backend/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("backend/pom.xml"), "").unwrap(); + fs::write( + root.join("backend/src/main/java/com/example/App.java"), + "package com.example; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{ + "id":"spring:com.example.App", + "name":"App", + "type":"spring-boot.maven", + "module":"backend", + "workingDirectory":".", + "mainClass":"com.example.App", + "jvmArguments":["-Xmx2g"], + "programArguments":["--dev"], + "mavenProfiles":["local"], + "toolchains":{"java":"project-jdk","maven":"project-maven"} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "migrated-plan", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "spring:com.example.App", + "debugPort": 5005 + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!([ + "-B", + "-ntp", + "-pl", + "backend", + "-P", + "local", + "-Dspring-boot.run.main-class=com.example.App", + "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5005 -Duser.language=en -Duser.country=US -Xmx2g", + "-Dspring-boot.run.arguments=--dev", + "spring-boot:run" + ]) + ); + assert_eq!(plan["data"]["workingDirectory"], "."); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); + + fs::remove_dir_all(root).unwrap(); +} + +/// `project.json` and the toolchain files sit under `.lithe` and carry their +/// own `version: 1`, unrelated to the run-configuration schema. Migration +/// must not touch them, or resolve rejects a perfectly valid project. +#[test] +fn migration_leaves_sidecar_documents_at_their_own_version() { + let root = temporary_root("run-config-sidecar"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/project.json"), + r#"{"version":1,"defaultRunConfiguration":"current-file"}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + r#"{"version":1,"toolchains":{}}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "sidecar", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!(resolved["data"]["version"], 2); + assert_eq!(resolved["data"]["defaultRunConfiguration"], "current-file"); + + fs::remove_dir_all(root).unwrap(); +} + +/// A non-Java service must reach a launch plan without acquiring a Java +/// toolchain or a JAVA_HOME it has no use for. +#[test] +fn process_configurations_launch_without_java_assumptions() { + let root = temporary_root("run-config-process"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("frontend")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"npm:dev", + "name":"web dev", + "provider":"npm.script", + "execution":"service", + "confidence":"declared", + "command":"npm", + "args":["run","dev"], + "cwd":"frontend", + "env":{"PORT":"3000"}, + "toolchains":{} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "process-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "npm:dev"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["command"], "npm"); + assert!(plan["data"]["executable"]["toolchain"].is_null()); + assert_eq!(plan["data"]["arguments"], serde_json::json!(["run", "dev"])); + assert_eq!(plan["data"]["workingDirectory"], "frontend"); + assert_eq!(plan["data"]["env"]["PORT"], "3000"); + assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn toolchain_backed_process_uses_the_generic_runtime_binding() { + let root = temporary_root("run-config-go-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"go:api","name":"Go API","provider":"go.main", + "execution":"application","args":["run","./cmd/api"],"cwd":".", + "env":{"APP_ENV":"dev"},"toolchains":{"runtime":"project-go"} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "go-toolchain-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "go:api"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-go"); + assert!(plan["data"]["executable"]["command"].is_null()); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!(["run", "./cmd/api"]) + ); + assert_eq!(plan["data"]["env"]["APP_ENV"], "dev"); + assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn pure_go_generation_does_not_require_java_or_add_java_current_file() { + let root = temporary_root("pure-go-no-jdk"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); + fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-pure-go", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["go.mod", "main.go"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true, "{generated}"); + let configurations = generated["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + assert!(configurations + .iter() + .any(|value| value["provider"] == "go.main")); + assert!(!configurations + .iter() + .any(|value| value["id"] == "current-file")); + assert!(generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn multi_language_generation_declares_runtime_requirements_and_versions() { + let root = temporary_root("generic-toolchain-requirements"); + for directory in ["python", "web", "worker/src"] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); + fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); + fs::write( + root.join("python/pyproject.toml"), + "[project]\nname = \"api\"\nrequires-python = \">=3.12\"\n[project.scripts]\napi = \"api:main\"\n", + ) + .unwrap(); + fs::write( + root.join("web/package.json"), + r#"{"engines":{"node":">=22.4"},"scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + fs::write( + root.join("worker/Cargo.toml"), + "[package]\nname = \"worker\"\nversion = \"0.1.0\"\nrust-version = \"1.82\"\n", + ) + .unwrap(); + fs::write(root.join("worker/src/main.rs"), "fn main() {}\n").unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-generic-requirements", + "command": "runConfig.generate", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true, "{generated}"); + let requirements = &generated["data"]["toolchainRequirements"]["toolchains"]; + for (id, kind, version) in [ + ("project-go", "go", "1.24"), + ("project-python", "python", "3.12"), + ("project-node", "node", "22.4"), + ("project-cargo", "rust", "1.82"), + ] { + assert_eq!(requirements[id]["type"], kind, "{requirements}"); + assert_eq!( + requirements[id]["minimumVersion"], version, + "{requirements}" + ); + } + assert!(requirements["project-jdk"].is_null(), "{requirements}"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn unknown_provider_without_an_executable_never_falls_into_maven() { + let root = temporary_root("run-config-unknown-provider"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"zig:app","name":"Zig App","provider":"zig.main", + "args":[],"cwd":".","toolchains":{} + }]}"#, + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "unknown-provider-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "zig:app"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], false, "{plan}"); + assert_eq!(plan["error"]["code"], "invalid_request"); + assert!(plan["error"]["message"] + .as_str() + .unwrap_or("") + .contains("command or runtime toolchain")); + + fs::remove_dir_all(root).unwrap(); +} + +/// Generic editor options must patch the common process shape. Writing +/// them into extensions.maven makes the UI appear to save successfully +/// while Go/Python/Node launch plans continue using the old arguments. +#[test] +fn process_options_update_common_arguments_and_environment() { + let root = temporary_root("run-config-process-options"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("backend")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"python:api","name":"API","provider":"python.script", + "command":"python3","args":["app.py"],"cwd":".","toolchains":{} + }]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-process-options", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "local", + "configurationId": "python:api", + "workingDirectory": "backend", + "arguments": "app.py --port 9000", + "environment": {"APP_ENV": "test"} + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true, "{updated}"); + let document: Value = serde_json::from_str( + updated["data"]["document"] + .as_str() + .expect("document string"), + ) + .unwrap(); + let patch = &document["configurations"][0]; + assert_eq!( + patch["args"], + serde_json::json!(["app.py", "--port", "9000"]) + ); + assert_eq!(patch["env"]["APP_ENV"], "test"); + assert!(patch["extensions"]["maven"].is_null()); + + fs::write( + root.join(".lithe/run/local.json"), + updated["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "updated-process-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "python:api"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!(["app.py", "--port", "9000"]) + ); + assert_eq!(plan["data"]["env"]["APP_ENV"], "test"); + + fs::remove_dir_all(root).unwrap(); +} + +/// An absolute or relative path would let a project manifest point the IDE +/// at an executable of its choosing. Commands resolve on PATH only. +#[test] +fn process_configurations_reject_path_qualified_commands() { + let root = temporary_root("run-config-process-path"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"evil","name":"evil","provider":"shell.command", + "command":"../../../usr/bin/curl","args":[],"cwd":".","toolchains":{} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "evil-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "evil"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], false); + assert_eq!(plan["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/rust/lithe-core/src/tests/support.rs b/rust/lithe-core/src/tests/support.rs new file mode 100644 index 00000000..11b19179 --- /dev/null +++ b/rust/lithe-core/src/tests/support.rs @@ -0,0 +1,19 @@ +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn fixture() -> Value { + let fixture_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../shared/fixtures/search/basic.json"); + serde_json::from_str(&fs::read_to_string(fixture_path).expect("fixture should be readable")) + .expect("fixture should be valid JSON") +} + +pub(super) fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!("lithe-core-{label}-{}-{nonce}", std::process::id())) +} From a9e943be204ebb88483bc4592ecfb2752edc756f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 19:25:38 +0800 Subject: [PATCH 27/38] Enforce Rust core package layout --- scripts/verify-rust-core-layout.sh | 27 +++++++++++++++++++++++++++ scripts/verify-rust-core.sh | 1 + 2 files changed, 28 insertions(+) create mode 100755 scripts/verify-rust-core-layout.sh diff --git a/scripts/verify-rust-core-layout.sh b/scripts/verify-rust-core-layout.sh new file mode 100755 index 00000000..2fa7f05d --- /dev/null +++ b/scripts/verify-rust-core-layout.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +cd "$ROOT_DIR" + +unexpected="$(find rust/lithe-core/src -maxdepth 1 -type f -name '*.rs' ! -name 'lib.rs' -print)" +if [[ -n "$unexpected" ]]; then + print -u2 -- "Rust Core implementation files must live in an owned package:" + print -u2 -- "$unexpected" + exit 1 +fi + +for package in protocol runtime project execution languages git lsp tests; do + if [[ ! -f "rust/lithe-core/src/$package/mod.rs" ]]; then + print -u2 -- "Rust Core package is missing its facade: $package" + exit 1 + fi +done + +legacy_pattern='crate::(error|model|command|cancellation|workspace|history|markdown|run_configuration|detectors|java|maven)\b' +if rg -n "$legacy_pattern" rust/lithe-core/src -g '*.rs'; then + print -u2 -- "Rust Core contains imports that bypass the package layout" + exit 1 +fi + +print "Rust Core package layout verification passed" diff --git a/scripts/verify-rust-core.sh b/scripts/verify-rust-core.sh index ec5a9292..e59f52e1 100755 --- a/scripts/verify-rust-core.sh +++ b/scripts/verify-rust-core.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +scripts/verify-rust-core-layout.sh cargo fmt --manifest-path rust/Cargo.toml -- --check cargo test --manifest-path rust/Cargo.toml From 9783629ef81f6d9898db07c2f2e95af11a8ca782 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 19:26:01 +0800 Subject: [PATCH 28/38] Document Rust core package boundaries --- docs/architecture/language-tooling.md | 20 ++++++++++++++++++++ docs/architecture/repository-layout.md | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 41fd4976..7b59643b 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -37,6 +37,26 @@ flowchart LR | Rust Core | LSP state、请求 ID、frame、UTF-16 位置、结果归一化、动态能力 | 可执行文件发现、子进程和线程模型 | | macOS adapter | 工具发现、环境变量、`Process`/`Pipe`、终止进程 | 语言功能路由和协议语义 | +Rust Core 的 LSP 实现统一收在 `rust/lithe-core/src/lsp/`,根模块只作为稳定 facade,command runtime 仍通过 `crate::lsp::*` 使用公开契约: + +```text +lsp/ +├── interface/ # 通用 LSP 协议、client state、transport 和 session host +│ ├── types.rs +│ ├── client.rs +│ ├── transport.rs +│ └── host.rs +├── lightweight/ # 不启动语言服务器的编辑、snippet 和当前文件符号能力 +│ ├── edits.rs +│ ├── snippets.rs +│ └── symbols.rs +└── languages/ # provider catalog 与语言/宿主模型 adapter + ├── catalog.rs + └── swift.rs +``` + +共享的 LSP position/range、client request/response/event 类型只能定义在 `interface/types.rs`。`lightweight` 可以依赖这些协议 DTO,但 `interface` 不依赖轻量实现。`languages/swift.rs` 目前只负责 Swift 宿主 DTO 与标准 LSP JSON 之间的转换,并不表示 SourceKit-LSP 私有协议;真正的服务器私有扩展仍应通过独立 adapter 接入。provider catalog 位于 `languages`,因为它描述可动态加载的语言/provider 元数据,而不是 client 状态机的一部分。 + ## Provider 路由 当前优先级由高到低为 `languageServer (200)`、预留的 `projectSymbols (100)`、`builtin (0)`。每次请求先按文件和功能过滤 provider,再按优先级路由: diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index 6e154eb6..6c6bed22 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -51,6 +51,26 @@ Both platforms consume `rust/lithe-core` through the same JSON envelope and command names. Shared behavior belongs in `shared/contracts/` and should have a fixture under `shared/fixtures/` before the second platform relies on it. +## Rust Core packages + +`rust/lithe-core/src/lib.rs` is only the crate composition root and public API. Rust implementation files are grouped by stable ownership boundary instead of being added beside `lib.rs`: + +```text +rust/lithe-core/src/ +├── protocol/ # command names, wire contracts, responses, errors, events, cancellation +├── runtime/ # JSON dispatcher and C ABI exports +├── project/ # files/search, local history, Markdown, Maven project inspection +├── execution/ # run configuration, launch/toolchain models, project detectors +├── languages/ # language-specific source inspection such as Java +├── git/ # Git validation, parsing, state, and mutations +├── lsp/ # generic LSP, lightweight fallback, provider/Swift adapters +└── tests/ # command-level tests grouped by the same domains +``` + +The dependency direction is `protocol <- domain packages <- runtime/FFI`. A domain may use protocol contracts, but it must not depend on the runtime dispatcher. `execution/types.rs` is the shared type layer for configuration and detectors, so those modules do not import each other through the package facade. `lsp/mod.rs` and the other package `mod.rs` files are compatibility facades; new implementation logic belongs in an owned submodule rather than in the facade. + +Moving Rust files must not change JSON command strings, Serde field names, error codes, or the exported C symbols. Directory-sensitive fixtures and embedded resources must use `CARGO_MANIFEST_DIR` instead of paths derived from a module's current depth. + ## Ownership rules | Shared Rust Core | Platform-owned adapters | From 9770a938f88da17fe61f4a9e8a9e6cd1ee127685 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 19:36:52 +0800 Subject: [PATCH 29/38] Track LSP process state in control center --- .../Lithe/Core/Ports/LanguageTooling.swift | 49 +++++ .../Lithe/Models/AppModel+FeatureState.swift | 7 +- Sources/Lithe/Models/AppModel.swift | 100 +++++++-- .../LanguageToolingSessionManager.swift | 147 ++++++++++++- .../Services/StdioLanguageServerSession.swift | 68 +++++- .../Components/LitheScrollViewChrome.swift | 47 ++++ .../Lithe/Views/LSPControlCenterView.swift | 206 +++++++++++++++--- .../Lithe/Views/LanguageServerSetupView.swift | 8 +- .../RunConfigurationIntegrationTests.swift | 58 +++++ 9 files changed, 631 insertions(+), 59 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 47d03b41..e2cbe283 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -257,6 +257,45 @@ struct LanguageServerCommand: Equatable, Sendable { let arguments: [ToolingJSONValue] } +enum LanguageServerLogLevel: String, Sendable { + case info + case warning + case error +} + +enum LanguageServerSessionState: Equatable, Sendable { + case starting + case running + case stopping + case stopped + case failed(exitCode: Int32?, message: String?) +} + +struct LanguageServerLogEntry: Identifiable, Equatable, Sendable { + let id: UUID + let timestamp: Date + let providerID: String + let level: LanguageServerLogLevel + let message: String + let detail: String? + + init( + id: UUID = UUID(), + timestamp: Date = Date(), + providerID: String, + level: LanguageServerLogLevel, + message: String, + detail: String? = nil + ) { + self.id = id + self.timestamp = timestamp + self.providerID = providerID + self.level = level + self.message = message + self.detail = detail + } +} + struct LanguageServerTextEdit: Equatable, Sendable { let range: LanguageServerRange let newText: String @@ -358,6 +397,8 @@ extension LanguageTestProvider { protocol LanguageServerSession: AnyObject { var isRunning: Bool { get } var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } + var onLog: ((LanguageServerLogLevel, String, String?) -> Void)? { get set } + var onStateChange: ((LanguageServerSessionState) -> Void)? { get set } var features: LanguageServerFeatureSet { get } var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get set } func start(rootURL: URL) throws @@ -419,6 +460,14 @@ extension LanguageServerSession { get { nil } set {} } + var onLog: ((LanguageServerLogLevel, String, String?) -> Void)? { + get { nil } + set {} + } + var onStateChange: ((LanguageServerSessionState) -> Void)? { + get { nil } + set {} + } func closeDocument(_: URL) {} } diff --git a/Sources/Lithe/Models/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel+FeatureState.swift index fc4309c5..3101275e 100644 --- a/Sources/Lithe/Models/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel+FeatureState.swift @@ -24,7 +24,12 @@ extension AppModel { var openDocuments: [EditorDocument] { documentFeature.openDocuments } var activeDocumentID: UUID? { get { documentFeature.activeDocumentID } - set { documentFeature.activeDocumentID = newValue } + set { + let previousDocumentID = documentFeature.activeDocumentID + documentFeature.activeDocumentID = newValue + guard previousDocumentID != newValue else { return } + activateCurrentDocumentLanguageServerIfAvailable() + } } var pendingCloseDocument: EditorDocument? { documentFeature.pendingCloseDocument } var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 32a28cf5..629fcb34 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -159,13 +159,38 @@ final class AppModel: ObservableObject, Identifiable { } func languageServerToolConfigurationDidChange(providerID: String) { + disabledLanguageServerProviderIDs.remove(providerID) + languageToolingSessions.stopLanguageServer(providerID: providerID) + languageToolingSessions.recordLanguageServerLog( + providerID: providerID, + level: .info, + message: "Language server tool configuration changed", + detail: "Workspace disable state cleared" + ) + } + + func isLanguageServerDisabledInCurrentWorkspace(providerID: String) -> Bool { + disabledLanguageServerProviderIDs.contains(providerID) + } + + func disableLanguageServerForCurrentWorkspace(providerID: String) { + disabledLanguageServerProviderIDs.insert(providerID) + languageToolingSessions.recordLanguageServerLog( + providerID: providerID, + level: .warning, + message: "Language server disabled in this workspace", + detail: "Manual stop" + ) languageToolingSessions.stopLanguageServer(providerID: providerID) } + private var gitFeatureObservation: AnyCancellable? private var documentFeatureObservation: AnyCancellable? private var javaFeatureObservation: AnyCancellable? private var languageToolingObservation: AnyCancellable? private var languageTestObservation: AnyCancellable? + private var languageServerStartupFailures: [String: String] = [:] + private var disabledLanguageServerProviderIDs: Set = [] private var recentProjectsStore: RecentProjectsStore { services.recentProjectsStore } private var workbenchLayoutStore: WorkbenchLayoutStore { services.workbenchLayoutStore } @@ -489,24 +514,38 @@ final class AppModel: ObservableObject, Identifiable { if let document = activeDocument, let descriptor = languageProviderCatalog.provider(for: document.url), descriptor.capabilities.contains(.languageServer) { + if disabledLanguageServerProviderIDs.contains(descriptor.id) { + return usesChinese + ? "\(descriptor.displayName) LSP 已在当前工作区禁用" + : "\(descriptor.displayName) LSP is disabled in this workspace" + } + if let state = languageToolingSessions.languageServerStates[descriptor.id], + case .failed = state { + return usesChinese + ? "\(descriptor.displayName) LSP 异常退出" + : "\(descriptor.displayName) LSP exited unexpectedly" + } if languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) { return usesChinese ? "\(descriptor.displayName) 语言服务器已就绪" : "\(descriptor.displayName) language server ready" } return usesChinese - ? "\(descriptor.displayName) 语言服务器可按需启动" - : "\(descriptor.displayName) language server available on demand" + ? "\(descriptor.displayName) 已由 catalog 声明,但当前没有运行中的 LSP 会话" + : "\(descriptor.displayName) is declared by the catalog, but no LSP session is running" } return usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" } func restartLanguageServers() { languageToolingSessions.stopAllLanguageServers() - if let activeDocument { - activateLanguageServerIfAvailable(for: activeDocument) - } - showNotification(settings.language == .simplifiedChinese ? "语言服务器已重启" : "Language servers restarted") + disabledLanguageServerProviderIDs.removeAll() + let didStart = activateCurrentDocumentLanguageServerIfAvailable() + showNotification( + didStart + ? (settings.language == .simplifiedChinese ? "语言服务器已启动" : "Language server started") + : (settings.language == .simplifiedChinese ? "当前没有运行中的 LSP 会话" : "No LSP session is running") + ) } func clearLanguageServerDiagnostics() { @@ -603,6 +642,8 @@ final class AppModel: ObservableObject, Identifiable { reloadLanguageProviderCatalog(for: normalizedURL) stopTerminalSessions() languageTestService.reset() + disabledLanguageServerProviderIDs.removeAll() + languageServerStartupFailures.removeAll() runtimeFeature.openProject(at: normalizedURL) mavenFeature.reset() runFeature.reset() @@ -905,14 +946,47 @@ final class AppModel: ObservableObject, Identifiable { } } - private func activateLanguageServerIfAvailable(for document: EditorDocument) { + @discardableResult + func activateCurrentDocumentLanguageServerIfAvailable() -> Bool { + guard let activeDocument else { return false } + return activateLanguageServerIfAvailable(for: activeDocument) + } + + @discardableResult + private func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { guard let workspaceURL, - languageProviderCatalog.provider(for: document.url) != nil else { return } - try? languageToolingSessions.synchronizeLanguageServer( - for: document.url, - text: document.text, - rootURL: workspaceURL - ) + let descriptor = languageProviderCatalog.provider(for: document.url) else { return false } + guard !disabledLanguageServerProviderIDs.contains(descriptor.id) else { + languageToolingSessions.recordLanguageServerLog( + providerID: descriptor.id, + level: .info, + message: "Language server activation skipped", + detail: "Disabled in this workspace" + ) + return false + } + do { + try languageToolingSessions.synchronizeLanguageServer( + for: document.url, + text: document.text, + rootURL: workspaceURL + ) + languageServerStartupFailures[descriptor.id] = nil + return languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) + } catch { + let message = error.localizedDescription + if languageServerStartupFailures[descriptor.id] != message { + languageServerStartupFailures[descriptor.id] = message + languageToolingSessions.recordLanguageServerLog( + providerID: descriptor.id, + level: .error, + message: "Language server activation failed", + detail: message + ) + showNotification("Could not start \(descriptor.displayName) language server: \(message)") + } + return false + } } func searchProject(options: ProjectSearchOptions = .default) async { diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 78b25dfa..57777063 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -26,6 +26,8 @@ enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { final class LanguageToolingSessionManager: ObservableObject { @Published private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] @Published private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:] + @Published private(set) var languageServerLogs: [LanguageServerLogEntry] = [] + @Published private(set) var languageServerStates: [String: LanguageServerSessionState] = [:] @Published private(set) var debugStates: [String: DebugAdapterState] = [:] @Published private(set) var lastDebugEvents: [String: DebugAdapterEvent] = [:] @Published private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:] @@ -38,6 +40,7 @@ final class LanguageToolingSessionManager: ObservableObject { private let runtimeFactory: (any LanguageProviderRuntimeFactory)? private var languageServers: [String: any LanguageServerSession] = [:] private var languageServerRoots: [String: URL] = [:] + private var languageServerSessionIdentities: [String: ObjectIdentifier] = [:] private var languageFeatureProviders: [any LanguageFeatureProvider] private var languageServerFeatureProviders: [String: LanguageServerFeatureProvider] = [:] private var debugAdapters: [String: any DebugAdapterSession] = [:] @@ -63,7 +66,13 @@ final class LanguageToolingSessionManager: ObservableObject { self.init(catalog: registry.catalog, runtimes: registry.toolingRuntimes) } - var activeLanguageServerIDs: Set { Set(languageServers.keys) } + var activeLanguageServerIDs: Set { + Set(languageServers.compactMap { providerID, session in + guard session.isRunning, + languageServerStates[providerID] == .running else { return nil } + return providerID + }) + } var activeDebugAdapterIDs: Set { Set(debugAdapters.keys) } func updateCatalog(_ catalog: LanguageProviderCatalog) { @@ -80,7 +89,9 @@ final class LanguageToolingSessionManager: ObservableObject { self.catalog = catalog let validProviderIDs = Set(catalog.descriptors.map(\.id)) languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } + languageServerStates = languageServerStates.filter { validProviderIDs.contains($0.key) } diagnostics = diagnostics.filter { catalog.provider(for: $0.key) != nil } + languageServerLogs = languageServerLogs.filter { validProviderIDs.contains($0.providerID) } for providerID in changedProviderIDs { stopLanguageServer(providerID: providerID) stopDebugAdapter(providerID: providerID) @@ -150,9 +161,22 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerRoots[descriptor.id] == normalizedRoot { session = active } else { + languageServerSessionIdentities[descriptor.id] = nil languageServers[descriptor.id]?.stop() languageServerFeatureProviders[descriptor.id] = nil + recordLanguageServerLog( + providerID: descriptor.id, + level: .info, + message: "Resolving language server", + detail: descriptor.languageServerLaunch?.executableNames.joined(separator: ", ") + ) guard let created = runtime.makeLanguageServerSession() else { + recordLanguageServerLog( + providerID: descriptor.id, + level: .error, + message: "Language server executable was not found", + detail: runtime.unavailableToolingMessage ?? descriptor.displayName + ) throw LanguageToolingSessionError.toolingUnavailable( runtime.unavailableToolingMessage ?? descriptor.displayName ) @@ -162,16 +186,41 @@ final class LanguageToolingSessionManager: ObservableObject { session: created, features: created.features ) + let sessionIdentity = ObjectIdentifier(created) + languageServerSessionIdentities[descriptor.id] = sessionIdentity + languageServerStates[descriptor.id] = .starting languageServerFeatureProviders[descriptor.id] = featureProvider - configureLanguageServerCallbacks(created, providerID: descriptor.id) + configureLanguageServerCallbacks( + created, + providerID: descriptor.id, + sessionIdentity: sessionIdentity + ) do { try created.start(rootURL: normalizedRoot) } catch { + languageServerSessionIdentities[descriptor.id] = nil + languageServerStates[descriptor.id] = .failed( + exitCode: nil, + message: error.localizedDescription + ) languageServerFeatureProviders[descriptor.id] = nil + recordLanguageServerLog( + providerID: descriptor.id, + level: .error, + message: "Language server failed to start", + detail: error.localizedDescription + ) throw error } languageServers[descriptor.id] = created languageServerRoots[descriptor.id] = normalizedRoot + languageServerStates[descriptor.id] = .running + recordLanguageServerLog( + providerID: descriptor.id, + level: .info, + message: "Language server session registered", + detail: normalizedRoot.path + ) session = created } try session.synchronize( @@ -191,20 +240,62 @@ final class LanguageToolingSessionManager: ObservableObject { diagnostics = [:] } + func clearLanguageServerLogs() { + languageServerLogs = [] + } + + func recordLanguageServerLog( + providerID: String, + level: LanguageServerLogLevel, + message: String, + detail: String? = nil + ) { + languageServerLogs.insert(LanguageServerLogEntry( + providerID: providerID, + level: level, + message: message, + detail: detail + ), at: 0) + if languageServerLogs.count > 100 { + languageServerLogs.removeLast(languageServerLogs.count - 100) + } + } + func stopLanguageServer(providerID: String) { + if languageServers[providerID] != nil { + recordLanguageServerLog( + providerID: providerID, + level: .info, + message: "Stopping language server", + detail: nil + ) + } + languageServerSessionIdentities[providerID] = nil languageServers.removeValue(forKey: providerID)?.stop() languageServerRoots[providerID] = nil languageServerFeatures[providerID] = nil languageServerFeatureProviders[providerID] = nil + languageServerStates[providerID] = .stopped } func stopAllLanguageServers() { - for session in languageServers.values { session.stop() } + for providerID in languageServers.keys { + recordLanguageServerLog( + providerID: providerID, + level: .info, + message: "Stopping language server", + detail: "Stop all" + ) + } + let sessions = Array(languageServers.values) diagnostics = [:] languageServerFeatures = [:] languageServers.removeAll() languageServerRoots.removeAll() + languageServerSessionIdentities.removeAll() languageServerFeatureProviders.removeAll() + languageServerStates = [:] + for session in sessions { session.stop() } } func navigate( @@ -490,13 +581,16 @@ final class LanguageToolingSessionManager: ObservableObject { } func stopAll() { - for session in languageServers.values { session.stop() } + let languageServerSessions = Array(languageServers.values) for session in debugAdapters.values { session.stop() } diagnostics = [:] languageServerFeatures = [:] languageServers.removeAll() languageServerRoots.removeAll() + languageServerSessionIdentities.removeAll() languageServerFeatureProviders.removeAll() + languageServerStates = [:] + for session in languageServerSessions { session.stop() } debugAdapters.removeAll() debugAdapterRoots.removeAll() debugStates = [:] @@ -704,15 +798,58 @@ final class LanguageToolingSessionManager: ObservableObject { private func configureLanguageServerCallbacks( _ session: any LanguageServerSession, - providerID: String + providerID: String, + sessionIdentity: ObjectIdentifier ) { session.onDiagnostics = { [weak self] fileURL, diagnostics in + guard self?.languageServerSessionIdentities[providerID] == sessionIdentity else { return } self?.diagnostics[fileURL.standardizedFileURL] = diagnostics } session.onFeaturesChange = { [weak self] features in guard let self else { return } + guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return } self.languageServerFeatures[providerID] = features self.languageServerFeatureProviders[providerID]?.updateFeatures(features) + self.recordLanguageServerLog( + providerID: providerID, + level: .info, + message: features.isEmpty ? "Language server features cleared" : "Language server features updated", + detail: features.isEmpty ? nil : "\(features.rawValue)" + ) + } + session.onLog = { [weak self] level, message, detail in + self?.recordLanguageServerLog( + providerID: providerID, + level: level, + message: message, + detail: detail + ) + } + session.onStateChange = { [weak self] state in + self?.handleLanguageServerState( + state, + providerID: providerID, + sessionIdentity: sessionIdentity + ) + } + } + + private func handleLanguageServerState( + _ state: LanguageServerSessionState, + providerID: String, + sessionIdentity: ObjectIdentifier + ) { + guard languageServerSessionIdentities[providerID] == sessionIdentity else { return } + languageServerStates[providerID] = state + switch state { + case .stopped, .failed: + languageServerSessionIdentities[providerID] = nil + languageServers[providerID] = nil + languageServerRoots[providerID] = nil + languageServerFeatures[providerID] = nil + languageServerFeatureProviders[providerID] = nil + case .starting, .running, .stopping: + break } } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index eef1cacd..b23cc642 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -128,6 +128,8 @@ final class StdioLanguageServerSession: LanguageServerSession { private var shutdownFallbackTask: Task? var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? + var onLog: ((LanguageServerLogLevel, String, String?) -> Void)? + var onStateChange: ((LanguageServerSessionState) -> Void)? private(set) var features: LanguageServerFeatureSet = [] var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? @@ -148,14 +150,28 @@ final class StdioLanguageServerSession: LanguageServerSession { process.onOutput = { [weak self] data in Task { @MainActor [weak self] in self?.receive(data) } } - process.onTermination = { [weak self] _ in - Task { @MainActor [weak self] in self?.resetTransientState() } + process.onError = { [weak self] data in + Task { @MainActor [weak self] in self?.receiveError(data) } + } + process.onStateChange = { [weak self] event in + Task { @MainActor [weak self] in self?.receiveStateChange(event) } + } + process.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in + self?.recordTermination(exitCode: exitCode) + self?.resetTransientState() + } } } var isRunning: Bool { process.isRunning } func start(rootURL: URL) throws { + onLog?( + .info, + "Starting language server", + ([executableURL.path] + arguments).joined(separator: " ") + ) try process.start(ProcessRequest( operationID: UUID().uuidString, executablePath: executableURL.path, @@ -571,6 +587,54 @@ final class StdioLanguageServerSession: LanguageServerSession { } } + private func receiveError(_ data: Data) { + let raw = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return } + onLog?(.warning, "Language server stderr", raw) + } + + private func receiveStateChange(_ event: ProcessLifecycleEvent) { + switch event.state { + case .starting: + onStateChange?(.starting) + onLog?(.info, "Language server process is starting", nil) + case .running: + onStateChange?(.running) + onLog?(.info, "Language server process is running", nil) + case .stopping: + onStateChange?(.stopping) + onLog?(.info, "Language server process is stopping", event.message) + case .finished: + let didFail = !isStopping && event.exitCode != 0 + onStateChange?( + didFail + ? .failed(exitCode: event.exitCode, message: event.message) + : .stopped + ) + let level: LanguageServerLogLevel = didFail ? .warning : .info + onLog?(level, "Language server process finished", exitCodeDetail(event.exitCode)) + case .failed: + onStateChange?(.failed(exitCode: event.exitCode, message: event.message)) + onLog?(.error, "Language server process failed to start", event.message) + } + } + + private func recordTermination(exitCode: Int32) { + if isStopping || exitCode == 0 { + onStateChange?(.stopped) + onLog?(.info, "Language server terminated", exitCodeDetail(exitCode)) + } else { + onStateChange?(.failed(exitCode: exitCode, message: nil)) + onLog?(.warning, "Language server terminated unexpectedly", exitCodeDetail(exitCode)) + } + } + + private func exitCodeDetail(_ exitCode: Int32?) -> String? { + guard let exitCode else { return nil } + return "exit code \(exitCode)" + } + private func resetTransientState() { shutdownFallbackTask?.cancel() shutdownFallbackTask = nil diff --git a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift index a6828e2a..e4d3ba97 100644 --- a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift +++ b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift @@ -23,6 +23,8 @@ struct LitheScrollViewChrome: NSViewRepresentable { final class ScrollViewProbe: NSView { var hideHorizontal: Bool var alwaysShowVertical: Bool + private weak var configuredScrollView: NSScrollView? + private var scrollWheelMonitor: Any? init(hideHorizontal: Bool, alwaysShowVertical: Bool) { self.hideHorizontal = hideHorizontal @@ -37,6 +39,9 @@ struct LitheScrollViewChrome: NSViewRepresentable { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + if window == nil { + removeScrollWheelMonitor() + } configureEnclosingScrollView() } @@ -58,6 +63,48 @@ struct LitheScrollViewChrome: NSViewRepresentable { scrollView.hasHorizontalScroller = false scrollView.horizontalScrollElasticity = .none } + configureScrollWheelMonitor(for: scrollView) + } + + deinit { + removeScrollWheelMonitor() + } + + private func configureScrollWheelMonitor(for scrollView: NSScrollView) { + guard alwaysShowVertical else { + removeScrollWheelMonitor() + configuredScrollView = nil + return + } + guard configuredScrollView !== scrollView || scrollWheelMonitor == nil else { return } + removeScrollWheelMonitor() + configuredScrollView = scrollView + scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self, weak scrollView] event in + guard let self, + let scrollView, + self.isEvent(event, inside: scrollView), + self.canScrollVertically(scrollView) else { return event } + scrollView.scrollWheel(with: event) + return nil + } + } + + private func removeScrollWheelMonitor() { + if let scrollWheelMonitor { + NSEvent.removeMonitor(scrollWheelMonitor) + self.scrollWheelMonitor = nil + } + } + + private func isEvent(_ event: NSEvent, inside scrollView: NSScrollView) -> Bool { + guard event.window === scrollView.window else { return false } + let point = scrollView.convert(event.locationInWindow, from: nil) + return scrollView.bounds.contains(point) + } + + private func canScrollVertically(_ scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + return documentView.bounds.height > scrollView.contentView.bounds.height + 0.5 } } } diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index 68f0d058..fcef9e4a 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -4,7 +4,7 @@ struct LSPControlCenterView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @State private var selectedProviderID: String? - @State private var isToolSetupPresented = false + @State private var isToolSetupExpanded = false private let metricColumns = [ GridItem(.flexible(), spacing: 8), @@ -23,13 +23,18 @@ struct LSPControlCenterView: View { ScrollView(.vertical) { VStack(spacing: 8) { globalControls - serverList - if let selected = selectedDescriptor { - serverDetail(selected) + if isToolSetupExpanded { + languageServerSetupPanel } else { - emptyDetail + serverList + if let selected = selectedDescriptor { + serverDetail(selected) + } else { + emptyDetail + } + eventLog + diagnosticLog } - diagnosticLog } .padding(8) } @@ -45,7 +50,7 @@ struct LSPControlCenterView: View { .foregroundStyle(LitheTheme.primaryText) Spacer(minLength: 0) Button { - isToolSetupPresented.toggle() + isToolSetupExpanded.toggle() } label: { LitheIDEAIcon( resourcePath: "general/gear.svg", @@ -55,23 +60,6 @@ struct LSPControlCenterView: View { } .litheIconButton() .help(copy.configureLanguageServers) - .popover(isPresented: $isToolSetupPresented, arrowEdge: .trailing) { - LanguageServerSetupView( - tools: model.languageServerTools, - providers: configurableLanguageServerDescriptors, - initialProviderID: selectedDescriptor?.id, - language: settings.language, - chooseExecutable: { descriptor in - model.chooseLanguageServerExecutable(providerName: descriptor.displayName) - }, - openOfficialDownload: { url in - model.openLanguageServerDownload(url) - }, - configurationChanged: { providerID in - model.languageServerToolConfigurationDidChange(providerID: providerID) - } - ) - } Button { model.isLSPControlCenterVisible = false } label: { @@ -86,6 +74,41 @@ struct LSPControlCenterView: View { .background(LitheTheme.toolHeader) } + private var languageServerSetupPanel: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + sectionTitle(copy.configureLanguageServers) + Spacer(minLength: 0) + Button { + isToolSetupExpanded = false + } label: { + Label(copy.backToOverview, systemImage: "chevron.left") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(LitheSecondaryButtonStyle()) + } + + LanguageServerSetupView( + tools: model.languageServerTools, + providers: configurableLanguageServerDescriptors, + initialProviderID: selectedDescriptor?.id, + language: settings.language, + chooseExecutable: { descriptor in + model.chooseLanguageServerExecutable(providerName: descriptor.displayName) + }, + openOfficialDownload: { url in + model.openLanguageServerDownload(url) + }, + configurationChanged: { providerID in + model.languageServerToolConfigurationDidChange(providerID: providerID) + }, + isEmbedded: true + ) + } + .padding(10) + .panelChrome() + } + private var globalControls: some View { VStack(spacing: 8) { HStack(spacing: 8) { @@ -98,7 +121,7 @@ struct LSPControlCenterView: View { .lineLimit(1) Spacer(minLength: 0) statusPill( - title: activeServerCount > 0 ? copy.lspActive : copy.onDemand, + title: activeServerCount > 0 ? copy.lspActive : copy.noRunningSession, color: activeServerCount > 0 ? LitheTheme.success : LitheTheme.secondaryText ) } @@ -175,12 +198,12 @@ struct LSPControlCenterView: View { .foregroundStyle(statusColor(metrics.status)) if metrics.status == .active || metrics.status == .error { Button { - model.languageToolingSessions.stopLanguageServer(providerID: descriptor.id) + model.disableLanguageServerForCurrentWorkspace(providerID: descriptor.id) } label: { Image(systemName: "stop") } .litheIconButton() - .help(copy.stopProvider(descriptor.displayName)) + .help(copy.disableProvider(descriptor.displayName)) } } .padding(.horizontal, 7) @@ -301,6 +324,69 @@ struct LSPControlCenterView: View { .panelChrome() } + private var eventLog: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + sectionTitle(copy.eventLog) + Spacer(minLength: 0) + Button { + model.languageToolingSessions.clearLanguageServerLogs() + } label: { + Image(systemName: "trash") + } + .litheIconButton() + .help(copy.clearEventLog) + } + + if model.languageToolingSessions.languageServerLogs.isEmpty { + Text(copy.noLanguageServerEvents) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + } else { + VStack(spacing: 1) { + ForEach(model.languageToolingSessions.languageServerLogs.prefix(6)) { entry in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 7) { + Image(systemName: logIcon(for: entry.level)) + .foregroundStyle(logColor(for: entry.level)) + .frame(width: 14) + Text(providerName(for: entry.providerID)) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Text(entry.timestamp.formatted(date: .omitted, time: .standard)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + Text(entry.message) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + if let detail = entry.detail, !detail.isEmpty { + Text(detail) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(2) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(LitheTheme.raised.opacity(0.38)) + ) + } + } + } + } + .padding(10) + .panelChrome() + } + private func capabilityGrid(_ descriptor: LanguageProviderDescriptor) -> some View { let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] ?? [] let rows: [LSPCapabilityRow] = [ @@ -402,6 +488,15 @@ struct LSPControlCenterView: View { .fill(LitheTheme.raised.opacity(0.55)) ) + Button { + selectedProviderID = descriptor.id + isToolSetupExpanded = true + } label: { + Label(copy.editToolPath, systemImage: "wrench.and.screwdriver") + .frame(maxWidth: .infinity) + } + .buttonStyle(LithePrimaryButtonStyle()) + Text(copy.configurationHint) .font(.system(size: 10.5)) .foregroundStyle(LitheTheme.secondaryText) @@ -499,10 +594,32 @@ struct LSPControlCenterView: View { } } + private func logColor(for level: LanguageServerLogLevel) -> Color { + switch level { + case .info: LitheTheme.accent + case .warning: LitheTheme.warning + case .error: LitheTheme.error + } + } + + private func logIcon(for level: LanguageServerLogLevel) -> String { + switch level { + case .info: "info.circle" + case .warning: "exclamationmark.triangle" + case .error: "xmark.octagon" + } + } + + private func providerName(for providerID: String) -> String { + model.languageProviderCatalog.descriptors.first { $0.id == providerID }?.displayName + ?? providerID + } + private func statusColor(_ status: LSPServerStatus) -> Color { switch status { case .active: LitheTheme.success case .stopped: LitheTheme.secondaryText + case .disabled: LitheTheme.warning case .error: LitheTheme.error } } @@ -633,7 +750,12 @@ struct LSPControlCenterView: View { || !features.isEmpty || !diagnostics.isEmpty - if diagnostics.contains(where: { $0.severity == .error }) { + if model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id) { + status = .disabled + } else if let state = model.languageToolingSessions.languageServerStates[descriptor.id], + case .failed = state { + status = .error + } else if diagnostics.contains(where: { $0.severity == .error }) { status = .error } else if model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) || !features.isEmpty { status = .active @@ -659,6 +781,7 @@ struct LSPControlCenterView: View { private enum LSPServerStatus { case active case stopped + case disabled case error } @@ -681,9 +804,10 @@ private struct LSPControlCenterCopy { var title: String { usesChinese ? "LSP 控制中心" : "LSP Control Center" } var hideControlCenter: String { usesChinese ? "隐藏 LSP 控制中心" : "Hide LSP Control Center" } var configureLanguageServers: String { usesChinese ? "配置语言服务器" : "Configure language servers" } + var backToOverview: String { usesChinese ? "返回概览" : "Back to overview" } var currentProject: String { usesChinese ? "当前项目:" : "Current project:" } var lspActive: String { usesChinese ? "LSP 运行中" : "LSP active" } - var onDemand: String { usesChinese ? "按需启动" : "On demand" } + var noRunningSession: String { usesChinese ? "无运行会话" : "No running session" } var restartAll: String { usesChinese ? "全部重启" : "Restart all" } var clearDiagnostics: String { usesChinese ? "清空诊断" : "Clear diagnostics" } var languageServers: String { usesChinese ? "语言服务器" : "Language Servers" } @@ -700,6 +824,11 @@ private struct LSPControlCenterCopy { var noLanguageServerDiagnostics: String { usesChinese ? "暂无语言服务器诊断。" : "No language server diagnostics." } + var eventLog: String { usesChinese ? "事件日志" : "Event Log" } + var clearEventLog: String { usesChinese ? "清空事件日志" : "Clear event log" } + var noLanguageServerEvents: String { + usesChinese ? "暂无 LSP 事件。" : "No LSP events." + } var capabilities: String { usesChinese ? "能力" : "Capabilities" } var languageServerCapability: String { usesChinese ? "语言服务器" : "Language Server" } var definition: String { usesChinese ? "定义" : "Definition" } @@ -719,6 +848,7 @@ private struct LSPControlCenterCopy { var activation: String { usesChinese ? "启动策略" : "Activation" } var builtinCatalog: String { usesChinese ? "内置 JSON" : "Built-in JSON" } var projectOverride: String { usesChinese ? "项目覆盖" : "Project override" } + var editToolPath: String { usesChinese ? "编辑 LSP 工具路径" : "Edit LSP tool path" } var noOpenFilesForProvider: String { usesChinese ? "这个语言服务器当前没有打开的文件。" : "No open files for this language server." } @@ -744,12 +874,14 @@ private struct LSPControlCenterCopy { case .always: usesChinese ? "始终启动" : "Always" case .onDemand: - usesChinese ? "按需启动" : "On demand" + usesChinese ? "按需激活" : "On demand" } } - func stopProvider(_ name: String) -> String { - usesChinese ? "停止 \(name)" : "Stop \(name)" + func disableProvider(_ name: String) -> String { + usesChinese + ? "在当前工作区禁用 \(name) LSP" + : "Disable \(name) LSP in this workspace" } func providerIDCopied(_ id: String) -> String { @@ -759,11 +891,11 @@ private struct LSPControlCenterCopy { func capabilityState(_ name: String, declared: Bool, active: Bool) -> String { if usesChinese { if active { return "\(name) 当前会话已启用。" } - if declared { return "\(name) 由 catalog 声明,但当前没有运行中的 LSP 会话。" } + if declared { return "\(name) 只是由 catalog 声明;当前没有运行中的 LSP 会话。" } return "\(name) 未由 catalog 声明。" } if active { return "\(name) is enabled in the current session." } - if declared { return "\(name) is declared by the catalog, but no LSP session is running." } + if declared { return "\(name) is only declared by the catalog; no LSP session is running." } return "\(name) is not declared by the catalog." } @@ -771,13 +903,15 @@ private struct LSPControlCenterCopy { if usesChinese { switch status { case .active: "运行中" - case .stopped: "已停止" + case .stopped: "未运行" + case .disabled: "已禁用" case .error: "错误" } } else { switch status { case .active: "Running" - case .stopped: "Stopped" + case .stopped: "Not running" + case .disabled: "Disabled" case .error: "Error" } } diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/LanguageServerSetupView.swift index 2b9ecf8a..6e34fe70 100644 --- a/Sources/Lithe/Views/LanguageServerSetupView.swift +++ b/Sources/Lithe/Views/LanguageServerSetupView.swift @@ -8,6 +8,7 @@ struct LanguageServerSetupView: View { let chooseExecutable: (LanguageProviderDescriptor) -> URL? let openOfficialDownload: (URL) -> Void let configurationChanged: (String) -> Void + let isEmbedded: Bool @State private var selectedProviderID: String @State private var executablePathDraft = "" @@ -20,7 +21,8 @@ struct LanguageServerSetupView: View { language: AppLanguage, chooseExecutable: @escaping (LanguageProviderDescriptor) -> URL?, openOfficialDownload: @escaping (URL) -> Void, - configurationChanged: @escaping (String) -> Void + configurationChanged: @escaping (String) -> Void, + isEmbedded: Bool = false ) { self.tools = tools self.providers = providers @@ -28,6 +30,7 @@ struct LanguageServerSetupView: View { self.chooseExecutable = chooseExecutable self.openOfficialDownload = openOfficialDownload self.configurationChanged = configurationChanged + self.isEmbedded = isEmbedded let initialID = initialProviderID.flatMap { id in providers.contains(where: { $0.id == id }) ? id : nil } ?? providers.first?.id ?? "" @@ -75,7 +78,8 @@ struct LanguageServerSetupView: View { } .litheScrollViewChrome(hideHorizontal: true) } - .frame(width: 430, height: 510) + .frame(width: isEmbedded ? nil : 430, height: isEmbedded ? nil : 510) + .frame(maxWidth: isEmbedded ? .infinity : nil, minHeight: isEmbedded ? 430 : nil) .background(LitheTheme.sidebar) .onChange(of: selectedProviderID) { _, providerID in executablePathDraft = tools.customExecutablePath(for: providerID) ?? "" diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 7571c825..562afc78 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1043,6 +1043,53 @@ struct RunConfigurationIntegrationTests { #expect(manager.activeLanguageServerIDs.isEmpty) } + @Test + func languageServerFailureClearsActiveSessionState() async throws { + let descriptor = LanguageProviderDescriptor( + id: "swift", + displayName: "Swift", + fileExtensions: ["swift"], + capabilities: [.languageServer, .formatting], + activationPolicy: .onDemand, + languageIdentifier: "swift", + languageServerLaunch: LanguageServerLaunchDescriptor( + executableNames: ["sourcekit-lsp"] + ) + ) + let runtimeService = ProjectRuntimeService( + runtimeLocator: RunTestRuntimeLocator(), + store: RunTestKeyValueStore() + ) + let process = RecordingRawProcessSession() + let source = URL(fileURLWithPath: "/tmp/swift-project/App.swift") + let runtime = StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + processFactory: { process }, + languageServerLaunch: descriptor.languageServerLaunch, + languageServerCore: TestLspClientCore(diagnosticURL: source) + ) + let manager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimes: [runtime] + ) + + try manager.synchronizeLanguageServer( + for: source, + text: "struct App {}\n", + rootURL: source.deletingLastPathComponent() + ) + #expect(manager.activeLanguageServerIDs == ["swift"]) + #expect(manager.languageServerStates["swift"] == .running) + + process.terminate(exitCode: 1) + await Self.drainMainActorTasks() + + #expect(manager.activeLanguageServerIDs.isEmpty) + #expect(manager.languageServerFeatures["swift"] == nil) + #expect(manager.languageServerStates["swift"] == .failed(exitCode: 1, message: nil)) + } + @Test func languageServerRuntimeStartsFromRustCatalogLaunchMetadata() async throws { let descriptor = LanguageProviderDescriptor( @@ -3541,6 +3588,17 @@ private final class RecordingRawProcessSession: RawProcessSession, @unchecked Se func send(_ input: Data) throws { sentData.append(input) } func stop() { isRunning = false } + func terminate(exitCode: Int32) { + isRunning = false + onStateChange?(ProcessLifecycleEvent( + operationID: requests.last?.operationID, + state: .finished, + exitCode: exitCode, + message: nil + )) + onTermination?(exitCode) + } + func emitJSON(_ object: [String: Any], splitAt: Int? = nil) { let body = try! JSONSerialization.data(withJSONObject: object) var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8) From a320d6d58a4c784f8b4ea6536d7f886ebd023bfd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 19:44:21 +0800 Subject: [PATCH 30/38] Validate LSP executables before launch --- .../Lithe/Core/Ports/LanguageTooling.swift | 3 + .../RustLanguageProviderCatalogSource.swift | 2 + .../Services/LanguageServerToolService.swift | 80 ++++++++++++- .../Lithe/Views/LanguageServerSetupView.swift | 4 +- .../LanguageServerToolServiceTests.swift | 110 ++++++++++++++++-- docs/architecture/language-tooling.md | 4 +- docs/reference/language-providers.schema.json | 4 + .../resources/lsp/language-providers.json | 3 +- rust/lithe-core/src/lsp/languages/catalog.rs | 2 + rust/lithe-core/src/lsp/tests.rs | 12 ++ 10 files changed, 208 insertions(+), 16 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index e2cbe283..54557549 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -59,17 +59,20 @@ enum ToolingActivationPolicy: String, Codable, Hashable, Sendable { struct LanguageServerLaunchDescriptor: Hashable, Sendable { let executableNames: [String] let arguments: [String] + let validationArguments: [String] let environment: [String: String] let initializationOptions: ToolingJSONValue? init( executableNames: [String], arguments: [String] = [], + validationArguments: [String] = [], environment: [String: String] = [:], initializationOptions: ToolingJSONValue? = nil ) { self.executableNames = executableNames self.arguments = arguments + self.validationArguments = validationArguments self.environment = environment self.initializationOptions = initializationOptions } diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift index c0901bef..c9040764 100644 --- a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift +++ b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift @@ -13,6 +13,7 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { private struct LanguageServerLaunchPayload: Decodable { let executableNames: [String] let arguments: [String] + let validationArguments: [String]? let environment: [String: String] let initializationOptions: ToolingJSONValue? @@ -20,6 +21,7 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { LanguageServerLaunchDescriptor( executableNames: executableNames, arguments: arguments, + validationArguments: validationArguments ?? [], environment: environment, initializationOptions: initializationOptions ) diff --git a/Sources/Lithe/Services/LanguageServerToolService.swift b/Sources/Lithe/Services/LanguageServerToolService.swift index 3668de88..f583ba59 100644 --- a/Sources/Lithe/Services/LanguageServerToolService.swift +++ b/Sources/Lithe/Services/LanguageServerToolService.swift @@ -40,6 +40,7 @@ enum LanguageServerInstallationState: Equatable, Sendable { enum LanguageServerToolConfigurationError: LocalizedError, Equatable { case executableRequired case executableInvalid(String) + case executableValidationFailed(path: String, message: String) case homebrewUnavailable case homebrewUnsupported(String) @@ -49,6 +50,8 @@ enum LanguageServerToolConfigurationError: LocalizedError, Equatable { "Choose a language-server executable." case .executableInvalid(let path): "The selected language-server path is not executable: \(path)" + case .executableValidationFailed(let path, let message): + "The selected language server could not run: \(path)\n\(message)" case .homebrewUnavailable: "Homebrew is not installed or is not available to Lithe." case .homebrewUnsupported(let provider): @@ -65,6 +68,7 @@ final class LanguageServerToolService: ObservableObject { private let runtimeService: ProjectRuntimeService private let processRunner: any ProcessRunner private let settingsStore: LanguageServerToolSettingsStore + private var validationCache: [ExecutableValidationKey: ExecutableValidationResult] = [:] init( runtimeService: ProjectRuntimeService, @@ -99,18 +103,22 @@ final class LanguageServerToolService: ObservableObject { if let path = customExecutablePath(for: descriptor.id), let executableURL = runtimeService.executableURL(at: path) { - result.append(RuntimeToolCandidate( + let candidate = RuntimeToolCandidate( command: descriptor.languageServerLaunch?.executableNames.first ?? descriptor.id, executableURL: executableURL, source: .custom, detail: "Lithe override" - )) - seen.insert(executableURL.path) + ) + if validate(candidate, for: descriptor).isUsable { + result.append(candidate) + seen.insert(executableURL.path) + } } for command in descriptor.languageServerLaunch?.executableNames ?? [] { for candidate in runtimeService.executableCandidates(command) { guard seen.insert(candidate.executableURL.path).inserted else { continue } + guard validate(candidate, for: descriptor).isUsable else { continue } result.append(candidate) } } @@ -121,7 +129,10 @@ final class LanguageServerToolService: ObservableObject { candidates(for: descriptor).first?.executableURL } - func setCustomExecutablePath(_ path: String, for providerID: String) throws { + func setCustomExecutablePath( + _ path: String, + for descriptor: LanguageProviderDescriptor + ) throws { let normalized = (path as NSString) .expandingTildeInPath .trimmingCharacters(in: .whitespacesAndNewlines) @@ -131,7 +142,21 @@ final class LanguageServerToolService: ObservableObject { guard let executableURL = runtimeService.executableURL(at: normalized) else { throw LanguageServerToolConfigurationError.executableInvalid(normalized) } - customExecutablePaths[providerID] = executableURL.path + validationCache.removeAll() + let candidate = RuntimeToolCandidate( + command: descriptor.languageServerLaunch?.executableNames.first ?? descriptor.id, + executableURL: executableURL, + source: .custom, + detail: "Lithe override" + ) + let validation = validate(candidate, for: descriptor) + guard validation.isUsable else { + throw LanguageServerToolConfigurationError.executableValidationFailed( + path: executableURL.path, + message: validation.message + ) + } + customExecutablePaths[descriptor.id] = executableURL.path settingsStore.save(customExecutablePaths) } @@ -171,6 +196,7 @@ final class LanguageServerToolService: ObservableObject { let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines) if result.succeeded { + validationCache.removeAll() installationStates[descriptor.id] = .installed( output.isEmpty ? "brew install \(formula) completed." : output ) @@ -180,6 +206,50 @@ final class LanguageServerToolService: ObservableObject { ) } } + + private func validate( + _ candidate: RuntimeToolCandidate, + for descriptor: LanguageProviderDescriptor + ) -> ExecutableValidationResult { + let arguments = descriptor.languageServerLaunch?.validationArguments ?? [] + guard !arguments.isEmpty else { return .usable } + let key = ExecutableValidationKey( + executablePath: candidate.executableURL.standardizedFileURL.path, + arguments: arguments + ) + if let cached = validationCache[key], + Date().timeIntervalSince(cached.checkedAt) < 30 { + return cached + } + let result = processRunner.run(ProcessRequest( + operationID: "lsp-validate-\(descriptor.id)-\(UUID().uuidString)", + executablePath: key.executablePath, + arguments: arguments, + environment: runtimeService.processEnvironment(), + timeoutMilliseconds: 5_000 + )) + let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + let validation = ExecutableValidationResult( + isUsable: result.succeeded, + message: output.isEmpty ? "Exited with code \(result.exitCode)." : output, + checkedAt: Date() + ) + validationCache[key] = validation + return validation + } +} + +private struct ExecutableValidationKey: Hashable { + let executablePath: String + let arguments: [String] +} + +private struct ExecutableValidationResult { + let isUsable: Bool + let message: String + let checkedAt: Date + + static let usable = Self(isUsable: true, message: "", checkedAt: .distantFuture) } private struct LanguageServerToolSettingsStore { diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/LanguageServerSetupView.swift index 6e34fe70..c5a5db75 100644 --- a/Sources/Lithe/Views/LanguageServerSetupView.swift +++ b/Sources/Lithe/Views/LanguageServerSetupView.swift @@ -311,7 +311,7 @@ struct LanguageServerSetupView: View { private func savePath() { guard let descriptor = selectedDescriptor else { return } do { - try tools.setCustomExecutablePath(executablePathDraft, for: descriptor.id) + try tools.setCustomExecutablePath(executablePathDraft, for: descriptor) executablePathDraft = tools.customExecutablePath(for: descriptor.id) ?? executablePathDraft validationMessage = nil configurationChanged(descriptor.id) @@ -391,6 +391,8 @@ private struct LanguageServerSetupCopy { usesChinese ? "请选择语言服务器可执行文件。" : error.localizedDescription case .executableInvalid(let path): usesChinese ? "该路径不是可执行文件:\(path)" : error.localizedDescription + case .executableValidationFailed(let path, let message): + usesChinese ? "语言服务器无法运行:\(path)\n\(message)" : error.localizedDescription case .homebrewUnavailable: usesChinese ? "Lithe 无法找到 Homebrew。" : error.localizedDescription case .homebrewUnsupported(let provider): diff --git a/Tests/LitheTests/LanguageServerToolServiceTests.swift b/Tests/LitheTests/LanguageServerToolServiceTests.swift index cab201f8..893c1723 100644 --- a/Tests/LitheTests/LanguageServerToolServiceTests.swift +++ b/Tests/LitheTests/LanguageServerToolServiceTests.swift @@ -28,7 +28,7 @@ struct LanguageServerToolServiceTests { store: store ) - try service.setCustomExecutablePath(customURL.path, for: descriptor.id) + try service.setCustomExecutablePath(customURL.path, for: descriptor) #expect(service.executableURL(for: descriptor) == customURL) #expect(service.candidates(for: descriptor).map(\.source) == [.custom, .homebrew]) @@ -52,7 +52,75 @@ struct LanguageServerToolServiceTests { ) #expect(throws: LanguageServerToolConfigurationError.executableInvalid("/missing/gopls")) { - try service.setCustomExecutablePath("/missing/gopls", for: "go") + try service.setCustomExecutablePath("/missing/gopls", for: goDescriptor()) + } + } + + @Test + func rejectsBrokenProxyAndFallsBackToHomebrewCandidate() { + let proxyURL = URL(fileURLWithPath: "/Users/test/.cargo/bin/rust-analyzer") + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/rust-analyzer") + let runner = LanguageServerToolTestProcessRunner(resultsByExecutablePath: [ + proxyURL.path: ProcessResult( + output: "error: Unknown binary 'rust-analyzer' in official toolchain", + exitCode: 1 + ), + brewURL.path: ProcessResult(output: "rust-analyzer 1.0.0", exitCode: 0) + ]) + let store = LanguageServerToolTestStore() + let service = LanguageServerToolService( + runtimeService: makeRuntime( + executablePaths: [proxyURL.path, brewURL.path], + candidates: [ + "rust-analyzer": [ + RuntimeToolCandidate( + command: "rust-analyzer", + executableURL: proxyURL, + source: .path + ), + RuntimeToolCandidate( + command: "rust-analyzer", + executableURL: brewURL, + source: .homebrew + ) + ] + ], + store: store + ), + processRunner: runner, + store: store + ) + + let candidates = service.candidates(for: rustDescriptor()) + + #expect(candidates.map(\.executableURL) == [brewURL]) + #expect(service.executableURL(for: rustDescriptor()) == brewURL) + #expect(runner.requests.count == 2) + #expect(runner.requests.allSatisfy { $0.arguments == ["--version"] }) + } + + @Test + func rejectsCustomExecutableThatFailsCatalogValidation() { + let proxyURL = URL(fileURLWithPath: "/Users/test/.cargo/bin/rust-analyzer") + let store = LanguageServerToolTestStore() + let runner = LanguageServerToolTestProcessRunner(resultsByExecutablePath: [ + proxyURL.path: ProcessResult(output: "unknown rustup proxy", exitCode: 1) + ]) + let service = LanguageServerToolService( + runtimeService: makeRuntime( + executablePaths: [proxyURL.path], + candidates: [:], + store: store + ), + processRunner: runner, + store: store + ) + + #expect(throws: LanguageServerToolConfigurationError.executableValidationFailed( + path: proxyURL.path, + message: "unknown rustup proxy" + )) { + try service.setCustomExecutablePath(proxyURL.path, for: rustDescriptor()) } } @@ -147,6 +215,25 @@ struct LanguageServerToolServiceTests { ) } + private func rustDescriptor() -> LanguageProviderDescriptor { + LanguageProviderDescriptor( + id: "rust", + displayName: "Rust", + fileExtensions: ["rs"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "rust", + languageServerLaunch: LanguageServerLaunchDescriptor( + executableNames: ["rust-analyzer"], + validationArguments: ["--version"] + ), + languageServerInstallation: LanguageServerInstallationDescriptor( + homebrewFormula: "rust-analyzer", + officialDownloadURL: URL(string: "https://rust-analyzer.github.io/manual.html#installation") + ) + ) + } + private func makeRuntime( executablePaths: Set, candidates: [String: [RuntimeToolCandidate]], @@ -201,19 +288,28 @@ private struct LanguageServerToolTestDiscovery: RuntimeToolDiscovery { private final class LanguageServerToolTestProcessRunner: ProcessRunner, @unchecked Sendable { private let lock = NSLock() private let result: ProcessResult - private var recordedRequest: ProcessRequest? + private let resultsByExecutablePath: [String: ProcessResult] + private var recordedRequests: [ProcessRequest] = [] - init(result: ProcessResult = ProcessResult(output: "", exitCode: 0)) { + init( + result: ProcessResult = ProcessResult(output: "", exitCode: 0), + resultsByExecutablePath: [String: ProcessResult] = [:] + ) { self.result = result + self.resultsByExecutablePath = resultsByExecutablePath } var lastRequest: ProcessRequest? { - lock.withLock { recordedRequest } + lock.withLock { recordedRequests.last } + } + + var requests: [ProcessRequest] { + lock.withLock { recordedRequests } } func run(_ request: ProcessRequest) -> ProcessResult { - lock.withLock { recordedRequest = request } - return result + lock.withLock { recordedRequests.append(request) } + return resultsByExecutablePath[request.executablePath] ?? result } } diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 7b59643b..65739d0d 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -120,7 +120,7 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 } ``` -`executableNames` 按顺序尝试,`environment` 覆盖 Lithe 进程环境中的同名键,`initializationOptions` 原样进入 LSP `initialize` 参数。catalog 更新后,session manager 会丢弃 descriptor 已变化的旧会话和 runtime,并由 runtime factory 根据新 descriptor 延迟创建 runtime;因此项目新增 provider 或覆盖启动命令不再受应用启动时的内置 runtime 列表限制。 +`executableNames` 按顺序尝试,`environment` 覆盖 Lithe 进程环境中的同名键,`initializationOptions` 原样进入 LSP `initialize` 参数。可选的 `validationArguments` 会在候选进入 session 解析前直接执行,例如 Rust provider 使用 `["--version"]` 排除存在于 `PATH` 但缺少组件的 rustup proxy。探测结果按路径和参数缓存 30 秒,退出码非零或超时的候选不会被视为可用。catalog 更新后,session manager 会丢弃 descriptor 已变化的旧会话和 runtime,并由 runtime factory 根据新 descriptor 延迟创建 runtime;因此项目新增 provider 或覆盖启动命令不再受应用启动时的内置 runtime 列表限制。 macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 @@ -159,7 +159,7 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid ## 接入新 LSP 的检查清单 -1. 在 catalog 中定义稳定 `id`、文件匹配规则、`languageId`、候选 executable 和参数。 +1. 在 catalog 中定义稳定 `id`、文件匹配规则、`languageId`、候选 executable 和参数;存在 shim/proxy 的工具应声明无副作用的 `validationArguments`。 2. 需要安装入口时定义 `languageServerInstallation`;不要在 Swift UI 中增加 provider ID 分支。 3. 确认服务器支持 stdio 和标准 `Content-Length` framing。 4. 不在 UI 或 manager 中按语言写分支;服务器差异应进入 descriptor 或独立 adapter。 diff --git a/docs/reference/language-providers.schema.json b/docs/reference/language-providers.schema.json index 16704f46..c73c4e3d 100644 --- a/docs/reference/language-providers.schema.json +++ b/docs/reference/language-providers.schema.json @@ -77,6 +77,10 @@ "type": "array", "items": { "type": "string" } }, + "validationArguments": { + "type": "array", + "items": { "type": "string" } + }, "environment": { "$ref": "#/$defs/stringMap" }, "initializationOptions": {} } diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json index 358a37ab..113241ef 100644 --- a/rust/lithe-core/resources/lsp/language-providers.json +++ b/rust/lithe-core/resources/lsp/language-providers.json @@ -79,7 +79,8 @@ "languageId": "rust", "languageServerLaunch": { "executableNames": ["rust-analyzer"], - "arguments": [] + "arguments": [], + "validationArguments": ["--version"] }, "languageServerInstallation": { "homebrewFormula": "rust-analyzer", diff --git a/rust/lithe-core/src/lsp/languages/catalog.rs b/rust/lithe-core/src/lsp/languages/catalog.rs index 7517b94d..8505a795 100644 --- a/rust/lithe-core/src/lsp/languages/catalog.rs +++ b/rust/lithe-core/src/lsp/languages/catalog.rs @@ -48,6 +48,8 @@ pub struct LspServerLaunchDescriptor { #[serde(default)] pub arguments: Vec, #[serde(default)] + pub validation_arguments: Vec, + #[serde(default)] pub environment: BTreeMap, #[serde(default)] pub initialization_options: Option, diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index d5aeb8bd..7f95daef 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -61,6 +61,18 @@ fn builtin_catalog_describes_market_lsp_providers() { go_installation.official_download_url.as_deref(), Some("https://go.dev/gopls/") ); + let rust = catalog + .providers + .iter() + .find(|provider| provider.id == "rust") + .expect("rust provider should exist"); + assert_eq!( + rust.language_server_launch + .as_ref() + .expect("rust launch descriptor should exist") + .validation_arguments, + vec!["--version".to_string()] + ); } #[test] From e883ce48750c9b956a86109c471156b16d733f76 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:19:15 +0800 Subject: [PATCH 31/38] Expose degraded language catalog loads --- Sources/Lithe/Application/AppServices.swift | 10 +- .../RustLanguageProviderCatalogSource.swift | 178 +++++++++++++++--- Sources/Lithe/Models/AppModel.swift | 9 +- .../Platform/MacOS/MacServiceContainer.swift | 5 +- .../LanguageProviderCatalogSourceTests.swift | 118 ++++++++++++ rust/lithe-core/src/lsp/languages/catalog.rs | 21 ++- rust/lithe-core/src/lsp/tests.rs | 24 +++ 7 files changed, 334 insertions(+), 31 deletions(-) create mode 100644 Tests/LitheTests/LanguageProviderCatalogSourceTests.swift diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/AppServices.swift index 8ba4e382..6a687d6d 100644 --- a/Sources/Lithe/Application/AppServices.swift +++ b/Sources/Lithe/Application/AppServices.swift @@ -17,6 +17,9 @@ final class AppServices { /// feature models while new composition should use this value. let languagePacks: LanguagePackRegistry let languageProviderCatalogSource: any LanguageProviderCatalogSource + /// Initial catalog load outcome, including whether startup fell back to a + /// compatibility catalog or rejected a workspace override. + let languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot /// Metadata-only provider catalog; providers are activated on demand. let languageProviderCatalog: LanguageProviderCatalog let runToolchainRegistry: RunToolchainRegistry @@ -53,7 +56,7 @@ final class AppServices { init( languageProviderCatalogSource: any LanguageProviderCatalogSource, - languageProviderCatalog: LanguageProviderCatalog? = nil, + languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot? = nil, languagePacks: LanguagePackRegistry? = nil, runToolchainRegistry: RunToolchainRegistry? = nil, languageToolingSessions: LanguageToolingSessionManager? = nil, @@ -88,7 +91,10 @@ final class AppServices { shortcutDetectorFactory: any ShortcutDetectorFactory ) { self.languageProviderCatalogSource = languageProviderCatalogSource - let resolvedCatalog = languageProviderCatalog ?? languageProviderCatalogSource.catalog(workspaceURL: nil) + let resolvedCatalogSnapshot = languageProviderCatalogSnapshot + ?? languageProviderCatalogSource.load(workspaceURL: nil) + self.languageProviderCatalogSnapshot = resolvedCatalogSnapshot + let resolvedCatalog = resolvedCatalogSnapshot.catalog let resolvedLanguagePacks = languagePacks ?? LanguagePackRegistry.standard( catalog: resolvedCatalog ) diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift index c9040764..ce0d9a4b 100644 --- a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift +++ b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift @@ -1,13 +1,102 @@ import Foundation import LitheRustCore +enum LanguageProviderCatalogOrigin: Equatable, Sendable { + case builtin + /// The associated URL is the accepted workspace configuration file. + case workspaceOverride(URL) + case compatibilityFallback +} + +enum LanguageProviderCatalogStatus: Equatable, Sendable { + case loaded + case degraded +} + +struct LanguageProviderCatalogIssue: Equatable, Sendable { + let path: String + let message: String +} + +struct LanguageProviderCatalogSnapshot: Sendable { + let catalog: LanguageProviderCatalog + let schemaVersion: Int? + let origin: LanguageProviderCatalogOrigin + let status: LanguageProviderCatalogStatus + let issues: [LanguageProviderCatalogIssue] + + var isDegraded: Bool { status == .degraded } + + init( + catalog: LanguageProviderCatalog, + schemaVersion: Int?, + origin: LanguageProviderCatalogOrigin, + issues: [LanguageProviderCatalogIssue] + ) { + self.catalog = catalog + self.schemaVersion = schemaVersion + self.origin = origin + self.issues = issues + if case .compatibilityFallback = origin { + status = .degraded + } else { + status = issues.isEmpty ? .loaded : .degraded + } + } +} + protocol LanguageProviderCatalogSource: Sendable { - func catalog(workspaceURL: URL?) -> LanguageProviderCatalog + func load(workspaceURL: URL?) -> LanguageProviderCatalogSnapshot +} + +extension LanguageProviderCatalogSource { + func catalog(workspaceURL: URL?) -> LanguageProviderCatalog { + load(workspaceURL: workspaceURL).catalog + } +} + +protocol RustLanguageProviderCatalogLoading: Sendable { + var isAvailable: Bool { get } + func languageProviderCatalogData(workspaceURL: URL?) -> Data? +} + +extension RustCoreBridge: RustLanguageProviderCatalogLoading { + func languageProviderCatalogData(workspaceURL: URL?) -> Data? { + let responsePointer: UnsafeMutablePointer? + if let workspaceURL { + responsePointer = workspaceURL.standardizedFileURL.path.withCString { + lithe_bridge_lsp_provider_catalog_json($0) + } + } else { + responsePointer = lithe_bridge_lsp_provider_catalog_json(nil) + } + guard let responsePointer else { return nil } + defer { lithe_bridge_free_string(responsePointer) } + guard let response = String(validatingUTF8: responsePointer) else { return nil } + return response.data(using: .utf8) + } } struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { + private enum CatalogOriginPayload: String, Decodable { + case builtin + case workspaceOverride + } + private struct CatalogPayload: Decodable { + let version: Int + let origin: CatalogOriginPayload let providers: [ProviderPayload] + let diagnostics: [CatalogDiagnosticPayload]? + } + + private struct CatalogDiagnosticPayload: Decodable { + let path: String + let message: String + + func makeIssue() -> LanguageProviderCatalogIssue { + LanguageProviderCatalogIssue(path: path, message: message) + } } private struct LanguageServerLaunchPayload: Decodable { @@ -72,41 +161,86 @@ struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource { } } - let core: RustCoreBridge + private let loader: any RustLanguageProviderCatalogLoading init(core: RustCoreBridge = RustCoreBridge()) { - self.core = core + loader = core + } + + init(loader: any RustLanguageProviderCatalogLoading) { + self.loader = loader } - func catalog(workspaceURL: URL? = nil) -> LanguageProviderCatalog { - guard let payload = loadPayload(workspaceURL: workspaceURL) else { - return .compatibilityFallback + func load(workspaceURL: URL? = nil) -> LanguageProviderCatalogSnapshot { + guard loader.isAvailable else { + return compatibilityFallback( + message: "The Rust core is unavailable. Lithe is using its compatibility language-provider catalog." + ) + } + guard let data = loader.languageProviderCatalogData(workspaceURL: workspaceURL) else { + return compatibilityFallback( + message: "The Rust core did not return a valid UTF-8 language-provider catalog." + ) + } + + let payload: CatalogPayload + do { + payload = try JSONDecoder().decode(CatalogPayload.self, from: data) + } catch { + return compatibilityFallback( + message: "The Rust language-provider catalog could not be decoded: \(error.localizedDescription)" + ) } - return LanguageProviderCatalog( - descriptors: payload.providers.map { $0.makeDescriptor() } + + let issues = (payload.diagnostics ?? []).map { $0.makeIssue() } + return LanguageProviderCatalogSnapshot( + catalog: LanguageProviderCatalog( + descriptors: payload.providers.map { $0.makeDescriptor() } + ), + schemaVersion: payload.version, + origin: resolvedOrigin( + payloadOrigin: payload.origin, + workspaceURL: workspaceURL + ), + issues: issues ) } - private func loadPayload(workspaceURL: URL?) -> CatalogPayload? { - guard core.isAvailable else { return nil } - let responsePointer: UnsafeMutablePointer? - if let workspaceURL { - responsePointer = workspaceURL.standardizedFileURL.path.withCString { - lithe_bridge_lsp_provider_catalog_json($0) + private func resolvedOrigin( + payloadOrigin: CatalogOriginPayload, + workspaceURL: URL? + ) -> LanguageProviderCatalogOrigin { + switch payloadOrigin { + case .workspaceOverride: + if let workspaceURL { + return .workspaceOverride( + workspaceURL.standardizedFileURL + .appendingPathComponent(".lithe") + .appendingPathComponent("lsp") + .appendingPathComponent("language-providers.json") + ) } - } else { - responsePointer = lithe_bridge_lsp_provider_catalog_json(nil) + return .builtin + case .builtin: + return .builtin } - guard let responsePointer else { return nil } - defer { lithe_bridge_free_string(responsePointer) } - let response = String(cString: responsePointer) - guard let data = response.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(CatalogPayload.self, from: data) + } + + private func compatibilityFallback(message: String) -> LanguageProviderCatalogSnapshot { + LanguageProviderCatalogSnapshot( + catalog: .compatibilityFallback, + schemaVersion: nil, + origin: .compatibilityFallback, + issues: [LanguageProviderCatalogIssue( + path: "rust:lsp-provider-catalog", + message: message + )] + ) } } extension LanguageProviderCatalog { static var standard: Self { - RustLanguageProviderCatalogSource().catalog() + RustLanguageProviderCatalogSource().load().catalog } } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 629fcb34..fd98c551 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -77,6 +77,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var isLSPControlCenterVisible = true @Published var isImplementationChooserVisible = false @Published private(set) var languageProviderCatalog: LanguageProviderCatalog + @Published private(set) var languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot @Published var languageNavigationProviderID: String? @Published var languageNavigationLocations: [LanguageNavigationLocation] = [] @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions @@ -198,6 +199,7 @@ final class AppModel: ObservableObject, Identifiable { self.settings = settings self.services = services languageProviderCatalog = services.languageProviderCatalog + languageProviderCatalogSnapshot = services.languageProviderCatalogSnapshot platformUI = services.platformUI workspaceFeature = WorkspaceFeatureModel( operations: services.workspaceOperations, @@ -761,9 +763,10 @@ final class AppModel: ObservableObject, Identifiable { } private func reloadLanguageProviderCatalog(for workspaceURL: URL?) { - let catalog = services.languageProviderCatalogSource.catalog(workspaceURL: workspaceURL) - languageProviderCatalog = catalog - languageToolingSessions.updateCatalog(catalog) + let snapshot = services.languageProviderCatalogSource.load(workspaceURL: workspaceURL) + languageProviderCatalogSnapshot = snapshot + languageProviderCatalog = snapshot.catalog + languageToolingSessions.updateCatalog(snapshot.catalog) } func openFile( diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index f2f971c6..aa027802 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -42,7 +42,8 @@ final class MacServiceContainer { toolDiscovery: MacRuntimeToolDiscovery() ) let languageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) - let languageProviderCatalog = languageProviderCatalogSource.catalog() + let languageProviderCatalogSnapshot = languageProviderCatalogSource.load() + let languageProviderCatalog = languageProviderCatalogSnapshot.catalog let languageServerTools = LanguageServerToolService( runtimeService: runtimeService, processRunner: processRunner, @@ -173,7 +174,7 @@ final class MacServiceContainer { ) services = AppServices( languageProviderCatalogSource: languageProviderCatalogSource, - languageProviderCatalog: languagePackRegistry.catalog, + languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, languagePacks: languagePackRegistry, runToolchainRegistry: runToolchainRegistry, languageToolingSessions: languageToolingSessions, diff --git a/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift b/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift new file mode 100644 index 00000000..cc08b623 --- /dev/null +++ b/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Language provider catalog source") +struct LanguageProviderCatalogSourceTests { + @Test + func unavailableRustCoreUsesAnExplicitDegradedCompatibilityFallback() { + let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: false, + data: nil + )) + + let snapshot = source.load() + + #expect(snapshot.origin == .compatibilityFallback) + #expect(snapshot.status == .degraded) + #expect(snapshot.isDegraded) + #expect(snapshot.schemaVersion == nil) + #expect(snapshot.issues.count == 1) + #expect(snapshot.issues[0].message.contains("compatibility")) + #expect(snapshot.catalog.provider(for: URL(fileURLWithPath: "/tmp/main.go"))?.id == "go") + } + + @Test + func invalidRustPayloadUsesAnExplicitDegradedCompatibilityFallback() { + let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: true, + data: Data("{".utf8) + )) + + let snapshot = source.load() + + #expect(snapshot.origin == .compatibilityFallback) + #expect(snapshot.status == .degraded) + #expect(snapshot.issues.count == 1) + #expect(snapshot.issues[0].message.contains("could not be decoded")) + } + + @Test + func rejectedWorkspaceOverridePreservesIssuesAndBuiltinCatalog() { + let workspaceURL = URL(fileURLWithPath: "/tmp/catalog-workspace", isDirectory: true) + let issuePath = workspaceURL + .appendingPathComponent(".lithe/lsp/language-providers.json") + .path + let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: true, + data: catalogPayload( + origin: "builtin", + diagnostics: """ + [{"path":"\(issuePath)","message":"expected value at line 1 column 20"}] + """ + ) + )) + + let snapshot = source.load(workspaceURL: workspaceURL) + + #expect(snapshot.origin == .builtin) + #expect(snapshot.status == .degraded) + #expect(snapshot.schemaVersion == 2) + #expect(snapshot.issues == [LanguageProviderCatalogIssue( + path: issuePath, + message: "expected value at line 1 column 20" + )]) + #expect(snapshot.catalog.provider(for: workspaceURL.appendingPathComponent("main.go"))?.id == "go") + } + + @Test + func acceptedWorkspaceOverrideReportsItsOriginWithoutDegradation() { + let workspaceURL = URL(fileURLWithPath: "/tmp/catalog-workspace", isDirectory: true) + let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: true, + data: catalogPayload(origin: "workspaceOverride") + )) + + let snapshot = source.load(workspaceURL: workspaceURL) + + #expect(snapshot.origin == .workspaceOverride( + workspaceURL.standardizedFileURL + .appendingPathComponent(".lithe") + .appendingPathComponent("lsp") + .appendingPathComponent("language-providers.json") + )) + #expect(snapshot.status == .loaded) + #expect(!snapshot.isDegraded) + #expect(snapshot.issues.isEmpty) + #expect(snapshot.schemaVersion == 2) + } + + private func catalogPayload(origin: String, diagnostics: String = "[]") -> Data { + Data(""" + { + "version": 2, + "origin": "\(origin)", + "providers": [{ + "id": "go", + "displayName": "Go", + "fileExtensions": ["go"], + "fileNames": [], + "fileNamePrefixes": [], + "capabilities": ["languageServer"], + "activationPolicy": "onDemand", + "languageId": "go", + "languageIdsByExtension": {}, + "languageIdsByFileName": {} + }], + "diagnostics": \(diagnostics) + } + """.utf8) + } +} + +private struct CatalogPayloadLoader: RustLanguageProviderCatalogLoading { + let isAvailable: Bool + let data: Data? + + func languageProviderCatalogData(workspaceURL _: URL?) -> Data? { data } +} diff --git a/rust/lithe-core/src/lsp/languages/catalog.rs b/rust/lithe-core/src/lsp/languages/catalog.rs index 8505a795..b2c2274b 100644 --- a/rust/lithe-core/src/lsp/languages/catalog.rs +++ b/rust/lithe-core/src/lsp/languages/catalog.rs @@ -12,11 +12,19 @@ const BUILTIN_LANGUAGE_PROVIDERS: &str = include_str!(concat!( #[serde(rename_all = "camelCase")] pub struct LspProviderCatalog { pub version: u32, + pub origin: LspProviderCatalogOrigin, pub providers: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspProviderCatalogOrigin { + Builtin, + WorkspaceOverride, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspProviderConfigDiagnostic { @@ -129,12 +137,19 @@ struct LspProviderPatch { } pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { let catalog = provider_catalog(workspace_root); - serde_json::to_string(&catalog) - .unwrap_or_else(|_| "{\"version\":1,\"providers\":[]}".to_string()) + serde_json::to_string(&catalog).unwrap_or_else(|_| { + concat!( + "{\"version\":1,\"origin\":\"builtin\",\"providers\":[],", + "\"diagnostics\":[{\"path\":\"builtin:lsp\",", + "\"message\":\"Could not serialize the language-provider catalog.\"}]}" + ) + .to_string() + }) } pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { let mut diagnostics = Vec::new(); + let mut origin = LspProviderCatalogOrigin::Builtin; let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { Ok(document) => document, Err(message) => { @@ -157,6 +172,7 @@ pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { Ok(raw) => match parse_document(&raw, &path.display().to_string()) { Ok(project_document) => { document = merge_documents(document, project_document); + origin = LspProviderCatalogOrigin::WorkspaceOverride; } Err(message) => diagnostics.push(LspProviderConfigDiagnostic { path: path.display().to_string(), @@ -180,6 +196,7 @@ pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { } LspProviderCatalog { version: document.version, + origin, providers, diagnostics, } diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 7f95daef..17b193c2 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -120,6 +120,7 @@ fn project_config_extends_and_overrides_builtin_catalog() { .unwrap(); let catalog = provider_catalog(Some(&root)); + assert_eq!(catalog.origin, LspProviderCatalogOrigin::WorkspaceOverride); assert!(catalog .providers .iter() @@ -170,6 +171,7 @@ fn ffi_json_is_a_standalone_catalog_document() { let raw = provider_catalog_json(None); let value: Value = serde_json::from_str(&raw).expect("catalog should be JSON"); assert_eq!(value["version"], 2); + assert_eq!(value["origin"], "builtin"); assert!(value["providers"].as_array().unwrap().len() > 10); assert!(value.get("ok").is_none()); assert!(value.get("command").is_none()); @@ -189,6 +191,7 @@ fn project_catalog_reports_unknown_configuration_fields() { .unwrap(); let catalog = provider_catalog(Some(&root)); + assert_eq!(catalog.origin, LspProviderCatalogOrigin::Builtin); assert_eq!(catalog.diagnostics.len(), 1); assert!(catalog.diagnostics[0].message.contains("unknown field")); assert!(catalog.providers.iter().any(|provider| provider.id == "go")); @@ -196,6 +199,27 @@ fn project_catalog_reports_unknown_configuration_fields() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn malformed_project_catalog_is_rejected_with_a_visible_diagnostic() { + let root = temporary_root("project-config-malformed"); + let config_path = root.join(".lithe/lsp/language-providers.json"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, r#"{"version":2,"providers":["#).unwrap(); + + let catalog = provider_catalog(Some(&root)); + + assert_eq!(catalog.origin, LspProviderCatalogOrigin::Builtin); + assert!(catalog.providers.iter().any(|provider| provider.id == "go")); + assert_eq!(catalog.diagnostics.len(), 1); + assert_eq!( + catalog.diagnostics[0].path, + config_path.display().to_string() + ); + assert!(catalog.diagnostics[0].message.contains("EOF")); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn text_edits_use_lsp_utf16_positions() { let response = apply_text_edits(ApplyTextEditsRequest { From c6135e6d5467480402f84089a59555d5b358ee4c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:25:38 +0800 Subject: [PATCH 32/38] Make LSP readiness and failures authoritative --- .../Lithe/Core/Ports/LanguageTooling.swift | 43 +- .../Lithe/Core/Ports/RawProcessSession.swift | 14 + .../MacOS/Process/MacRawProcessSession.swift | 8 +- .../LanguageToolingSessionManager.swift | 228 +++--- .../Services/StdioLanguageServerSession.swift | 658 ++++++++++++++---- .../RunConfigurationIntegrationTests.swift | 364 +++++++++- rust/lithe-core/src/lsp/interface/client.rs | 6 +- rust/lithe-core/src/lsp/tests.rs | 26 + 8 files changed, 1092 insertions(+), 255 deletions(-) diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 54557549..31ad8557 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -204,12 +204,38 @@ struct LanguageServerRange: Equatable, Sendable { let end: LanguageServerPosition } +struct LanguageServerDiagnosticRelatedInformation: Equatable, Sendable { + let fileURL: URL + let range: LanguageServerRange + let message: String +} + struct LanguageServerDiagnostic: Equatable, Sendable { let range: LanguageServerRange let severity: Int? let message: String let source: String? let code: String? + let tags: [Int] + let relatedInformation: [LanguageServerDiagnosticRelatedInformation] + + init( + range: LanguageServerRange, + severity: Int?, + message: String, + source: String?, + code: String?, + tags: [Int] = [], + relatedInformation: [LanguageServerDiagnosticRelatedInformation] = [] + ) { + self.range = range + self.severity = severity + self.message = message + self.source = source + self.code = code + self.tags = tags + self.relatedInformation = relatedInformation + } } struct LanguageServerLocation: Equatable, Sendable { @@ -267,13 +293,19 @@ enum LanguageServerLogLevel: String, Sendable { } enum LanguageServerSessionState: Equatable, Sendable { - case starting - case running + case startingProcess + case initializing + case ready case stopping case stopped case failed(exitCode: Int32?, message: String?) } +struct LanguageServerInfo: Equatable, Sendable { + let name: String + let version: String? +} + struct LanguageServerLogEntry: Identifiable, Equatable, Sendable { let id: UUID let timestamp: Date @@ -404,6 +436,8 @@ protocol LanguageServerSession: AnyObject { var onStateChange: ((LanguageServerSessionState) -> Void)? { get set } var features: LanguageServerFeatureSet { get } var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get set } + var serverInfo: LanguageServerInfo? { get } + var onServerInfoChange: ((LanguageServerInfo?) -> Void)? { get set } func start(rootURL: URL) throws func synchronize(fileURL: URL, text: String, languageID: String) throws func closeDocument(_ fileURL: URL) @@ -471,6 +505,11 @@ extension LanguageServerSession { get { nil } set {} } + var serverInfo: LanguageServerInfo? { nil } + var onServerInfoChange: ((LanguageServerInfo?) -> Void)? { + get { nil } + set {} + } func closeDocument(_: URL) {} } diff --git a/Sources/Lithe/Core/Ports/RawProcessSession.swift b/Sources/Lithe/Core/Ports/RawProcessSession.swift index 7081bb36..88487ddb 100644 --- a/Sources/Lithe/Core/Ports/RawProcessSession.swift +++ b/Sources/Lithe/Core/Ports/RawProcessSession.swift @@ -1,5 +1,19 @@ import Foundation +enum RawProcessSessionError: LocalizedError, Equatable, Sendable { + case notRunning + case standardInputUnavailable + + var errorDescription: String? { + switch self { + case .notRunning: + "The process is not running." + case .standardInputUnavailable: + "The process does not have an open standard-input pipe." + } + } +} + protocol RawProcessSession: AnyObject, Sendable { var isRunning: Bool { get } var onOutput: (@Sendable (Data) -> Void)? { get set } diff --git a/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift b/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift index efbf535b..d96d3e7c 100644 --- a/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift +++ b/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift @@ -112,7 +112,13 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable { } func send(_ input: Data) throws { - try inputPipe?.fileHandleForWriting.write(contentsOf: input) + guard process?.isRunning == true else { + throw RawProcessSessionError.notRunning + } + guard let inputPipe else { + throw RawProcessSessionError.standardInputUnavailable + } + try inputPipe.fileHandleForWriting.write(contentsOf: input) } func stop() { diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 57777063..bfc36c4a 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -28,6 +28,7 @@ final class LanguageToolingSessionManager: ObservableObject { @Published private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:] @Published private(set) var languageServerLogs: [LanguageServerLogEntry] = [] @Published private(set) var languageServerStates: [String: LanguageServerSessionState] = [:] + @Published private(set) var languageServerInfos: [String: LanguageServerInfo] = [:] @Published private(set) var debugStates: [String: DebugAdapterState] = [:] @Published private(set) var lastDebugEvents: [String: DebugAdapterEvent] = [:] @Published private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:] @@ -69,7 +70,7 @@ final class LanguageToolingSessionManager: ObservableObject { var activeLanguageServerIDs: Set { Set(languageServers.compactMap { providerID, session in guard session.isRunning, - languageServerStates[providerID] == .running else { return nil } + languageServerStates[providerID] == .ready else { return nil } return providerID }) } @@ -90,6 +91,7 @@ final class LanguageToolingSessionManager: ObservableObject { let validProviderIDs = Set(catalog.descriptors.map(\.id)) languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } languageServerStates = languageServerStates.filter { validProviderIDs.contains($0.key) } + languageServerInfos = languageServerInfos.filter { validProviderIDs.contains($0.key) } diagnostics = diagnostics.filter { catalog.provider(for: $0.key) != nil } languageServerLogs = languageServerLogs.filter { validProviderIDs.contains($0.providerID) } for providerID in changedProviderIDs { @@ -188,7 +190,9 @@ final class LanguageToolingSessionManager: ObservableObject { ) let sessionIdentity = ObjectIdentifier(created) languageServerSessionIdentities[descriptor.id] = sessionIdentity - languageServerStates[descriptor.id] = .starting + languageServerStates[descriptor.id] = .startingProcess + languageServerFeatures[descriptor.id] = nil + languageServerInfos[descriptor.id] = nil languageServerFeatureProviders[descriptor.id] = featureProvider configureLanguageServerCallbacks( created, @@ -203,6 +207,8 @@ final class LanguageToolingSessionManager: ObservableObject { exitCode: nil, message: error.localizedDescription ) + languageServerFeatures[descriptor.id] = nil + languageServerInfos[descriptor.id] = nil languageServerFeatureProviders[descriptor.id] = nil recordLanguageServerLog( providerID: descriptor.id, @@ -214,7 +220,6 @@ final class LanguageToolingSessionManager: ObservableObject { } languageServers[descriptor.id] = created languageServerRoots[descriptor.id] = normalizedRoot - languageServerStates[descriptor.id] = .running recordLanguageServerLog( providerID: descriptor.id, level: .info, @@ -274,6 +279,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServers.removeValue(forKey: providerID)?.stop() languageServerRoots[providerID] = nil languageServerFeatures[providerID] = nil + languageServerInfos[providerID] = nil languageServerFeatureProviders[providerID] = nil languageServerStates[providerID] = .stopped } @@ -290,6 +296,7 @@ final class LanguageToolingSessionManager: ObservableObject { let sessions = Array(languageServers.values) diagnostics = [:] languageServerFeatures = [:] + languageServerInfos = [:] languageServers.removeAll() languageServerRoots.removeAll() languageServerSessionIdentities.removeAll() @@ -388,17 +395,14 @@ final class LanguageToolingSessionManager: ObservableObject { rootURL _: URL, completion: @escaping (Result) -> Void ) throws { - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.rename( - fileURL: fileURL, - position: position, - newName: newName, - completion: completion - ) - return - } catch {} + if let session = readyLanguageServerSession(for: fileURL) { + try session.rename( + fileURL: fileURL, + position: position, + newName: newName, + completion: completion + ) + return } throw unavailableLanguageServerError(for: fileURL) } @@ -416,12 +420,9 @@ final class LanguageToolingSessionManager: ObservableObject { ], completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void ) throws { - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.format(fileURL: fileURL, completion: completion) - return - } catch {} + if let session = readyLanguageServerSession(for: fileURL) { + try session.format(fileURL: fileURL, completion: completion) + return } throw unavailableLanguageServerError(for: fileURL) } @@ -434,17 +435,14 @@ final class LanguageToolingSessionManager: ObservableObject { rootURL _: URL, completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void ) throws { - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.codeActions( - fileURL: fileURL, - range: range, - diagnostics: diagnostics, - completion: completion - ) - return - } catch {} + if let session = readyLanguageServerSession(for: fileURL) { + try session.codeActions( + fileURL: fileURL, + range: range, + diagnostics: diagnostics, + completion: completion + ) + return } throw unavailableLanguageServerError(for: fileURL) } @@ -462,12 +460,9 @@ final class LanguageToolingSessionManager: ObservableObject { capability: "execute command" ) } - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.execute(command, fileURL: fileURL, completion: completion) - return - } catch {} + if let session = readyLanguageServerSession(for: fileURL) { + try session.execute(command, fileURL: fileURL, completion: completion) + return } throw unavailableLanguageServerError(for: fileURL) } @@ -485,12 +480,9 @@ final class LanguageToolingSessionManager: ObservableObject { capability: "completion item resolve" ) } - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.resolveCompletion(item, fileURL: fileURL, completion: completion) - return - } catch {} + if let session = readyLanguageServerSession(for: fileURL) { + try session.resolveCompletion(item, fileURL: fileURL, completion: completion) + return } throw unavailableLanguageServerError(for: fileURL) } @@ -508,12 +500,9 @@ final class LanguageToolingSessionManager: ObservableObject { capability: "code action resolve" ) } - if let session = languageServerSession(for: fileURL), - session.isRunning { - do { - try session.resolveCodeAction(action, fileURL: fileURL, completion: completion) - return - } catch {} + if let session = readyLanguageServerSession(for: fileURL) { + try session.resolveCodeAction(action, fileURL: fileURL, completion: completion) + return } throw unavailableLanguageServerError(for: fileURL) } @@ -585,6 +574,7 @@ final class LanguageToolingSessionManager: ObservableObject { for session in debugAdapters.values { session.stop() } diagnostics = [:] languageServerFeatures = [:] + languageServerInfos = [:] languageServers.removeAll() languageServerRoots.removeAll() languageServerSessionIdentities.removeAll() @@ -644,9 +634,28 @@ final class LanguageToolingSessionManager: ObservableObject { } private func unavailableLanguageServerError(for fileURL: URL) -> LanguageToolingSessionError { - let provider = catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension + guard let descriptor = catalog.provider(for: fileURL) else { + return .noProvider(fileExtension: fileURL.pathExtension.lowercased()) + } + let provider = descriptor.displayName + if let state = languageServerStates[descriptor.id] { + switch state { + case .startingProcess: + return .toolingUnavailable("\(provider) language server process is starting.") + case .initializing: + return .toolingUnavailable("\(provider) language server is initializing.") + case .failed(_, let message): + return .toolingUnavailable(message ?? "\(provider) language server failed.") + case .stopping: + return .toolingUnavailable("\(provider) language server is stopping.") + case .stopped: + break + case .ready: + return .capabilityUnavailable(provider: provider, capability: "this language feature") + } + } return .toolingUnavailable( - "\(provider) language server is waiting for the Rust LSP host integration." + "\(provider) language server is not ready." ) } @@ -655,6 +664,14 @@ final class LanguageToolingSessionManager: ObservableObject { return languageServers[descriptor.id] } + private func readyLanguageServerSession(for fileURL: URL) -> (any LanguageServerSession)? { + guard let descriptor = catalog.provider(for: fileURL), + languageServerStates[descriptor.id] == .ready, + let session = languageServers[descriptor.id], + session.isRunning else { return nil } + return session + } + private func featureContext( fileURL: URL, text: String, @@ -699,31 +716,27 @@ final class LanguageToolingSessionManager: ObservableObject { } do { try providers[index].completions(in: context) { [self] result in - var merged = items - var labels = seenLabels - if case .success(let providerItems) = result { + switch result { + case .success(let providerItems): + var merged = items + var labels = seenLabels for item in providerItems where labels.insert(item.label).inserted { merged.append(item) } + routeCompletions( + providers: providers, + index: index + 1, + context: context, + items: merged, + seenLabels: labels, + completion: completion + ) + case .failure(let error): + completion(.failure(error)) } - routeCompletions( - providers: providers, - index: index + 1, - context: context, - items: merged, - seenLabels: labels, - completion: completion - ) } } catch { - routeCompletions( - providers: providers, - index: index + 1, - context: context, - items: items, - seenLabels: seenLabels, - completion: completion - ) + completion(.failure(error)) } } @@ -739,24 +752,22 @@ final class LanguageToolingSessionManager: ObservableObject { } do { try providers[index].hover(in: context) { [self] result in - if case .success(let hover?) = result { + switch result { + case .success(let hover?): completion(.success(hover)) - } else { + case .success(nil): routeHover( providers: providers, index: index + 1, context: context, completion: completion ) + case .failure(let error): + completion(.failure(error)) } } } catch { - routeHover( - providers: providers, - index: index + 1, - context: context, - completion: completion - ) + completion(.failure(error)) } } @@ -773,26 +784,25 @@ final class LanguageToolingSessionManager: ObservableObject { } do { try providers[index].navigate(method: method, in: context) { [self] result in - if case .success(let locations) = result, !locations.isEmpty { - completion(.success(locations)) - } else { - routeNavigation( - providers: providers, - index: index + 1, - method: method, - context: context, - completion: completion - ) + switch result { + case .success(let locations): + if locations.isEmpty { + routeNavigation( + providers: providers, + index: index + 1, + method: method, + context: context, + completion: completion + ) + } else { + completion(.success(locations)) + } + case .failure(let error): + completion(.failure(error)) } } } catch { - routeNavigation( - providers: providers, - index: index + 1, - method: method, - context: context, - completion: completion - ) + completion(.failure(error)) } } @@ -808,8 +818,12 @@ final class LanguageToolingSessionManager: ObservableObject { session.onFeaturesChange = { [weak self] features in guard let self else { return } guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return } - self.languageServerFeatures[providerID] = features self.languageServerFeatureProviders[providerID]?.updateFeatures(features) + if self.languageServerStates[providerID] == .ready { + self.languageServerFeatures[providerID] = features + } else { + self.languageServerFeatures[providerID] = nil + } self.recordLanguageServerLog( providerID: providerID, level: .info, @@ -817,6 +831,13 @@ final class LanguageToolingSessionManager: ObservableObject { detail: features.isEmpty ? nil : "\(features.rawValue)" ) } + session.onServerInfoChange = { [weak self] info in + guard let self else { return } + guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return } + if self.languageServerStates[providerID] == .ready { + self.languageServerInfos[providerID] = info + } + } session.onLog = { [weak self] level, message, detail in self?.recordLanguageServerLog( providerID: providerID, @@ -829,7 +850,8 @@ final class LanguageToolingSessionManager: ObservableObject { self?.handleLanguageServerState( state, providerID: providerID, - sessionIdentity: sessionIdentity + sessionIdentity: sessionIdentity, + session: session ) } } @@ -837,7 +859,8 @@ final class LanguageToolingSessionManager: ObservableObject { private func handleLanguageServerState( _ state: LanguageServerSessionState, providerID: String, - sessionIdentity: ObjectIdentifier + sessionIdentity: ObjectIdentifier, + session: any LanguageServerSession ) { guard languageServerSessionIdentities[providerID] == sessionIdentity else { return } languageServerStates[providerID] = state @@ -847,9 +870,14 @@ final class LanguageToolingSessionManager: ObservableObject { languageServers[providerID] = nil languageServerRoots[providerID] = nil languageServerFeatures[providerID] = nil + languageServerInfos[providerID] = nil languageServerFeatureProviders[providerID] = nil - case .starting, .running, .stopping: - break + case .startingProcess, .initializing, .stopping: + languageServerFeatures[providerID] = nil + languageServerInfos[providerID] = nil + case .ready: + languageServerFeatures[providerID] = session.features + languageServerInfos[providerID] = session.serverInfo } } diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift index b23cc642..2ee7564b 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift @@ -115,6 +115,8 @@ final class StdioLanguageServerSession: LanguageServerSession { private let arguments: [String] private let environment: [String: String] private let initializationOptions: ToolingJSONValue? + private let initializeTimeoutNanoseconds: UInt64 + private let requestTimeoutNanoseconds: UInt64 private let process: any RawProcessSession private let core: any LspClientCore private var legacyState: ToolingJSONValue? @@ -122,9 +124,12 @@ final class StdioLanguageServerSession: LanguageServerSession { private var readBuffer = Data() private var openedDocumentURIs: Set = [] private var pendingDocuments: [String: PendingDocument] = [:] - private var responseHandlers: [String: (RustCoreBridge.LspClientEventPayload) -> Void] = [:] + private var responseHandlers: [String: PendingResponse] = [:] + private var responseTimeoutTasks: [String: Task] = [:] private var isInitialized = false private var isStopping = false + private var state: LanguageServerSessionState = .stopped + private var initializeTimeoutTask: Task? private var shutdownFallbackTask: Task? var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? @@ -132,12 +137,16 @@ final class StdioLanguageServerSession: LanguageServerSession { var onStateChange: ((LanguageServerSessionState) -> Void)? private(set) var features: LanguageServerFeatureSet = [] var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? + private(set) var serverInfo: LanguageServerInfo? + var onServerInfoChange: ((LanguageServerInfo?) -> Void)? init( executableURL: URL, arguments: [String], environment: [String: String], initializationOptions: ToolingJSONValue? = nil, + initializeTimeoutNanoseconds: UInt64 = 10_000_000_000, + requestTimeoutNanoseconds: UInt64 = 30_000_000_000, process: any RawProcessSession, core: any LspClientCore = RustCoreBridge() ) { @@ -145,6 +154,8 @@ final class StdioLanguageServerSession: LanguageServerSession { self.arguments = arguments self.environment = environment self.initializationOptions = initializationOptions + self.initializeTimeoutNanoseconds = initializeTimeoutNanoseconds + self.requestTimeoutNanoseconds = requestTimeoutNanoseconds self.process = process self.core = core process.onOutput = { [weak self] data in @@ -167,36 +178,53 @@ final class StdioLanguageServerSession: LanguageServerSession { var isRunning: Bool { process.isRunning } func start(rootURL: URL) throws { + transition(to: .startingProcess) onLog?( .info, "Starting language server", ([executableURL.path] + arguments).joined(separator: " ") ) - try process.start(ProcessRequest( - operationID: UUID().uuidString, - executablePath: executableURL.path, - arguments: arguments, - workingDirectory: rootURL.standardizedFileURL.path, - environment: environment, - keepsStandardInputOpen: true - )) - if let sessionCore = core as? any LspSessionCore, - let response = sessionCore.lspSessionCreate( - rootURL: rootURL, - initializationOptions: initializationOptions - ) { - sessionID = response.sessionId - apply(response) - } else if let response = core.lspClientInitialize( - rootURL: rootURL, - initializationOptions: initializationOptions - ) { - apply(response) + do { + try process.start(ProcessRequest( + operationID: UUID().uuidString, + executablePath: executableURL.path, + arguments: arguments, + workingDirectory: rootURL.standardizedFileURL.path, + environment: environment, + keepsStandardInputOpen: true + )) + transition(to: .initializing) + if let sessionCore = core as? any LspSessionCore { + guard let response = sessionCore.lspSessionCreate( + rootURL: rootURL, + initializationOptions: initializationOptions + ) else { + throw StdioLanguageServerSessionError.coreRejected("initialize") + } + sessionID = response.sessionId + try apply(response) + } else { + guard let response = core.lspClientInitialize( + rootURL: rootURL, + initializationOptions: initializationOptions + ) else { + throw StdioLanguageServerSessionError.coreRejected("initialize") + } + try apply(response) + } + if !isInitialized { + scheduleInitializeTimeout() + } + } catch { + failSession(error) + throw error } } func synchronize(fileURL: URL, text: String, languageID: String) throws { - guard sessionID != nil || legacyState != nil else { return } + guard sessionID != nil || legacyState != nil else { + throw StdioLanguageServerSessionError.notReady + } let standardizedURL = fileURL.standardizedFileURL let uri = standardizedURL.absoluteString guard isInitialized else { @@ -209,7 +237,8 @@ final class StdioLanguageServerSession: LanguageServerSession { } if let sessionCore = core as? any LspSessionCore, let sessionID { - let response = openedDocumentURIs.contains(uri) + let wasOpen = openedDocumentURIs.contains(uri) + let response = wasOpen ? sessionCore.lspSessionChangeDocument( sessionID: sessionID, fileURL: standardizedURL, @@ -221,11 +250,24 @@ final class StdioLanguageServerSession: LanguageServerSession { languageID: languageID, text: text ) - if !openedDocumentURIs.contains(uri) { openedDocumentURIs.insert(uri) } - if let response { apply(response) } + guard let response else { + let error = StdioLanguageServerSessionError.coreRejected( + wasOpen ? "textDocument/didChange" : "textDocument/didOpen" + ) + failSession(error) + throw error + } + do { + try apply(response) + if !wasOpen { openedDocumentURIs.insert(uri) } + } catch { + failSession(error) + throw error + } } else if let legacyState { let response: RustCoreBridge.LspClientResponsePayload? - if openedDocumentURIs.contains(uri) { + let wasOpen = openedDocumentURIs.contains(uri) + if wasOpen { response = core.lspClientChangeDocument( state: legacyState, fileURL: standardizedURL, @@ -238,9 +280,21 @@ final class StdioLanguageServerSession: LanguageServerSession { languageID: languageID, text: text ) - openedDocumentURIs.insert(uri) } - if let response { apply(response) } + guard let response else { + let error = StdioLanguageServerSessionError.coreRejected( + wasOpen ? "textDocument/didChange" : "textDocument/didOpen" + ) + failSession(error) + throw error + } + do { + try apply(response) + if !wasOpen { openedDocumentURIs.insert(uri) } + } catch { + failSession(error) + throw error + } } } @@ -248,20 +302,36 @@ final class StdioLanguageServerSession: LanguageServerSession { let standardizedURL = fileURL.standardizedFileURL let uri = standardizedURL.absoluteString pendingDocuments[uri] = nil - guard openedDocumentURIs.remove(uri) != nil else { return } + guard openedDocumentURIs.contains(uri) else { return } if let sessionCore = core as? any LspSessionCore, - let sessionID, - let response = sessionCore.lspSessionCloseDocument( + let sessionID { + guard let response = sessionCore.lspSessionCloseDocument( sessionID: sessionID, fileURL: standardizedURL - ) { - apply(response) - } else if let legacyState, - let response = core.lspClientCloseDocument( + ) else { + failSession(StdioLanguageServerSessionError.coreRejected("textDocument/didClose")) + return + } + do { + try apply(response) + openedDocumentURIs.remove(uri) + } catch { + failSession(error) + } + } else if let legacyState { + guard let response = core.lspClientCloseDocument( state: legacyState, fileURL: standardizedURL - ) { - apply(response) + ) else { + failSession(StdioLanguageServerSessionError.coreRejected("textDocument/didClose")) + return + } + do { + try apply(response) + openedDocumentURIs.remove(uri) + } catch { + failSession(error) + } } } @@ -274,9 +344,10 @@ final class StdioLanguageServerSession: LanguageServerSession { method: "textDocument/completion", fileURL: fileURL, position: position - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.BuiltinCompletionPayload.self) - .map { $0.makeModels() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.BuiltinCompletionPayload.self) + }.map { $0.makeModels() }) } } @@ -289,9 +360,10 @@ final class StdioLanguageServerSession: LanguageServerSession { method: "textDocument/hover", fileURL: fileURL, position: position - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.BuiltinHoverPayload.self) - .map { $0.hover?.makeModel() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.BuiltinHoverPayload.self) + }.map { $0.hover?.makeModel() }) } } @@ -301,9 +373,10 @@ final class StdioLanguageServerSession: LanguageServerSession { position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { - try requestFeature(method: method, fileURL: fileURL, position: position) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.BuiltinNavigationPayload.self) - .map { $0.makeModels() }) + try requestFeature(method: method, fileURL: fileURL, position: position) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.BuiltinNavigationPayload.self) + }.map { $0.makeModels() }) } } @@ -318,9 +391,10 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: fileURL, position: position, newName: newName - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.LspWorkspaceEditPayload.self) - .map { $0.makeModel() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.LspWorkspaceEditPayload.self) + }.map { $0.makeModel() }) } } @@ -332,9 +406,10 @@ final class StdioLanguageServerSession: LanguageServerSession { method: "textDocument/formatting", fileURL: fileURL, position: nil - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.LspFormattingPayload.self) - .map { $0.makeModels() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.LspFormattingPayload.self) + }.map { $0.makeModels() }) } } @@ -350,9 +425,10 @@ final class StdioLanguageServerSession: LanguageServerSession { position: nil, range: range, diagnostics: diagnostics - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.LspCodeActionsPayload.self) - .map { $0.makeModels() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.LspCodeActionsPayload.self) + }.map { $0.makeModels() }) } } @@ -366,9 +442,10 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: fileURL, position: nil, completionItem: item - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.LspCompletionResolvePayload.self) - .map { $0.makeModel() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.LspCompletionResolvePayload.self) + }.map { $0.makeModel() }) } } @@ -382,9 +459,10 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: fileURL, position: nil, codeAction: action - ) { event in - completion(Self.decodeEventResult(event, as: RustCoreBridge.LspCodeActionResolvePayload.self) - .map { $0.makeModel() }) + ) { result in + completion(result.flatMap { + Self.decodeEventResult($0, as: RustCoreBridge.LspCodeActionResolvePayload.self) + }.map { $0.makeModel() }) } } @@ -398,71 +476,93 @@ final class StdioLanguageServerSession: LanguageServerSession { fileURL: fileURL, position: nil, command: command - ) { event in - if let error = event.error { - completion(.failure(StdioLanguageServerSessionError.serverError(error))) - } else { - completion(.success(())) + ) { result in + switch result { + case .success(let event): + if let error = event.error { + completion(.failure(StdioLanguageServerSessionError.serverError(error))) + } else { + completion(.success(())) + } + case .failure(let error): + completion(.failure(error)) } } } func stop() { guard process.isRunning else { + failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped) resetTransientState() + transition(to: .stopped) return } guard !isStopping, isInitialized else { - forceStop() + forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped) return } - if let sessionCore = core as? any LspSessionCore, - let sessionID, - let response = sessionCore.lspSessionShutdown(sessionID: sessionID) { - isStopping = true - apply(response) - } else if let legacyState, - let response = core.lspClientShutdown(state: legacyState) { - isStopping = true - apply(response) - } else { - forceStop() + failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped) + isStopping = true + transition(to: .stopping) + do { + if let sessionCore = core as? any LspSessionCore, + let sessionID, + let response = sessionCore.lspSessionShutdown(sessionID: sessionID) { + try apply(response) + } else if let legacyState, + let response = core.lspClientShutdown(state: legacyState) { + try apply(response) + } else { + forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped) + return + } + } catch { + failSession(error) return } shutdownFallbackTask?.cancel() // The task intentionally retains the session after its manager removes it. shutdownFallbackTask = Task { @MainActor [self] in - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard !Task.isCancelled else { return } - forceStop() + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + return + } + forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped) } } - private func apply(_ response: RustCoreBridge.LspClientResponsePayload) { + private func apply(_ response: RustCoreBridge.LspClientResponsePayload) throws { + try validateInitializeEvents(response.events) + for message in response.messages { + try sendRawJSON(message) + } legacyState = response.state updateFeatures(from: response.state) - response.messages.forEach(sendRawJSON) - handle(response.events) + try handle(response.events) } - private func apply(_ response: RustCoreBridge.LspSessionResponsePayload) { + private func apply(_ response: RustCoreBridge.LspSessionResponsePayload) throws { + try validateInitializeEvents(response.events) + for message in response.messages { + try sendRawJSON(message) + } updateFeatures(capabilityNames: response.serverCapabilities) - response.messages.forEach(sendRawJSON) - handle(response.events) + try handle(response.events) } - private func handle(_ events: [RustCoreBridge.LspClientEventPayload]) { + private func handle(_ events: [RustCoreBridge.LspClientEventPayload]) throws { for event in events { if let requestID = event.requestId, - let handler = responseHandlers.removeValue(forKey: requestID) { - handler(event) + let pending = responseHandlers.removeValue(forKey: requestID) { + responseTimeoutTasks.removeValue(forKey: requestID)?.cancel() + pending.completion(.success(event)) } - if event.method == "initialize", event.kind == "response" { - isInitialized = true - flushPendingDocuments() + if event.method == "initialize" { + try handleInitialize(event) } if event.method == "shutdown" { - forceStop() + forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped) return } if event.kind == "diagnostics", @@ -484,7 +584,7 @@ final class StdioLanguageServerSession: LanguageServerSession { completionItem: LanguageServerCompletionItem? = nil, codeAction: LanguageServerCodeAction? = nil, command: LanguageServerCommand? = nil, - completion: @escaping (RustCoreBridge.LspClientEventPayload) -> Void + completion: @escaping (Result) -> Void ) throws { guard isInitialized else { throw StdioLanguageServerSessionError.notReady @@ -494,6 +594,8 @@ final class StdioLanguageServerSession: LanguageServerSession { } let messages: [String] let events: [RustCoreBridge.LspClientEventPayload] + var updatedLegacyState: ToolingJSONValue? + var updatedCapabilityNames: [String]? if let sessionCore = core as? any LspSessionCore, let sessionID, let response = sessionCore.lspSessionRequest( @@ -508,7 +610,7 @@ final class StdioLanguageServerSession: LanguageServerSession { codeAction: codeAction, command: command ) { - updateFeatures(capabilityNames: response.serverCapabilities) + updatedCapabilityNames = response.serverCapabilities messages = response.messages events = response.events } else if let legacyState, @@ -524,66 +626,120 @@ final class StdioLanguageServerSession: LanguageServerSession { codeAction: codeAction, command: command ) { - self.legacyState = response.state + updatedLegacyState = response.state messages = response.messages events = response.events } else { throw StdioLanguageServerSessionError.requestRejected } - for message in messages { - if let requestID = Self.requestID(from: message) { - responseHandlers[requestID] = completion + guard let requestID = messages.lazy.compactMap({ Self.requestID(from: $0) }).first else { + throw StdioLanguageServerSessionError.missingRequestID + } + responseHandlers[requestID] = PendingResponse( + method: method, + fileURL: fileURL.standardizedFileURL, + completion: completion + ) + do { + for message in messages { + try sendRawJSON(message) + } + if let updatedLegacyState { + legacyState = updatedLegacyState + updateFeatures(from: updatedLegacyState) + } else if let updatedCapabilityNames { + updateFeatures(capabilityNames: updatedCapabilityNames) } - sendRawJSON(message) + try handle(events) + } catch { + responseHandlers[requestID] = nil + responseTimeoutTasks.removeValue(forKey: requestID)?.cancel() + failSession(error) + throw error + } + if responseHandlers[requestID] != nil { + scheduleRequestTimeout(requestID: requestID) } - handle(events) } - private func flushPendingDocuments() { - let documents = pendingDocuments.values - pendingDocuments.removeAll() - for document in documents { - try? synchronize( + private func flushPendingDocuments() throws { + let documents = pendingDocuments.sorted { $0.key < $1.key } + for (uri, document) in documents { + try synchronize( fileURL: document.fileURL, text: document.text, languageID: document.languageID ) + pendingDocuments[uri] = nil } } - private func sendRawJSON(_ message: String) { + private func sendRawJSON(_ message: String) throws { + guard process.isRunning else { + throw StdioLanguageServerSessionError.transportFailure("Language server process is not running.") + } if let frame = core.lspFrameMessage(message)?.frame, let data = frame.data(using: .utf8) { - try? process.send(data) + do { + try process.send(data) + } catch { + throw StdioLanguageServerSessionError.transportFailure(error.localizedDescription) + } return } - guard let body = message.data(using: .utf8) else { return } + guard let body = message.data(using: .utf8) else { + throw StdioLanguageServerSessionError.transportFailure("Could not encode LSP message as UTF-8.") + } var fallback = Data("Content-Length: \(body.count)\r\n\r\n".utf8) fallback.append(body) - try? process.send(fallback) + do { + try process.send(fallback) + } catch { + throw StdioLanguageServerSessionError.transportFailure(error.localizedDescription) + } } private func receive(_ data: Data) { + guard !isFailed else { return } + receiveServerData(data) + } + + private func receiveServerData(_ data: Data) { guard let parsed = core.lspParseServerMessages( buffer: Array(readBuffer), chunk: Array(data) - ) else { return } + ) else { + failSession(StdioLanguageServerSessionError.protocolFailure( + "Rust core could not parse the language server output." + )) + return + } readBuffer = Data(parsed.buffer) - for message in parsed.messages { - if let sessionCore = core as? any LspSessionCore, - let sessionID, - let response = sessionCore.lspSessionApplyServerMessage( - sessionID: sessionID, - message: message - ) { - apply(response) - } else if let legacyState, - let response = core.lspClientApplyServerMessage( + do { + for message in parsed.messages { + if let sessionCore = core as? any LspSessionCore, + let sessionID { + guard let response = sessionCore.lspSessionApplyServerMessage( + sessionID: sessionID, + message: message + ) else { + throw StdioLanguageServerSessionError.coreRejected("server message") + } + try apply(response) + } else if let legacyState { + guard let response = core.lspClientApplyServerMessage( state: legacyState, message: message - ) { - apply(response) + ) else { + throw StdioLanguageServerSessionError.coreRejected("server message") + } + try apply(response) + } else { + throw StdioLanguageServerSessionError.notReady + } } + } catch { + failSession(error) } } @@ -597,36 +753,47 @@ final class StdioLanguageServerSession: LanguageServerSession { private func receiveStateChange(_ event: ProcessLifecycleEvent) { switch event.state { case .starting: - onStateChange?(.starting) + if state == .stopped { transition(to: .startingProcess) } onLog?(.info, "Language server process is starting", nil) case .running: - onStateChange?(.running) + if state == .startingProcess { transition(to: .initializing) } onLog?(.info, "Language server process is running", nil) case .stopping: - onStateChange?(.stopping) + if !isFailed { transition(to: .stopping) } onLog?(.info, "Language server process is stopping", event.message) case .finished: - let didFail = !isStopping && event.exitCode != 0 - onStateChange?( - didFail - ? .failed(exitCode: event.exitCode, message: event.message) - : .stopped + onLog?( + isStopping ? .info : .warning, + "Language server process finished", + event.message ?? exitCodeDetail(event.exitCode) ) - let level: LanguageServerLogLevel = didFail ? .warning : .info - onLog?(level, "Language server process finished", exitCodeDetail(event.exitCode)) + if let exitCode = event.exitCode { + recordTermination(exitCode: exitCode) + } case .failed: - onStateChange?(.failed(exitCode: event.exitCode, message: event.message)) - onLog?(.error, "Language server process failed to start", event.message) + failSession( + StdioLanguageServerSessionError.transportFailure( + event.message ?? "Language server process failed to start." + ), + exitCode: event.exitCode, + stopProcess: false + ) } } private func recordTermination(exitCode: Int32) { - if isStopping || exitCode == 0 { - onStateChange?(.stopped) + guard !isFailed, state != .stopped else { return } + if isStopping { + failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped) + resetTransientState() + transition(to: .stopped) onLog?(.info, "Language server terminated", exitCodeDetail(exitCode)) } else { - onStateChange?(.failed(exitCode: exitCode, message: nil)) - onLog?(.warning, "Language server terminated unexpectedly", exitCodeDetail(exitCode)) + failSession( + StdioLanguageServerSessionError.sessionTerminated(exitCode), + exitCode: exitCode, + stopProcess: false + ) } } @@ -636,8 +803,15 @@ final class StdioLanguageServerSession: LanguageServerSession { } private func resetTransientState() { + initializeTimeoutTask?.cancel() + initializeTimeoutTask = nil shutdownFallbackTask?.cancel() shutdownFallbackTask = nil + responseTimeoutTasks.values.forEach { $0.cancel() } + responseTimeoutTasks = [:] + if !responseHandlers.isEmpty { + failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped) + } if let sessionCore = core as? any LspSessionCore, let sessionID { sessionCore.lspSessionDestroy(sessionID: sessionID) @@ -647,20 +821,184 @@ final class StdioLanguageServerSession: LanguageServerSession { readBuffer = Data() openedDocumentURIs = [] pendingDocuments = [:] - responseHandlers = [:] isInitialized = false isStopping = false if !features.isEmpty { features = [] onFeaturesChange?([]) } + if serverInfo != nil { + serverInfo = nil + onServerInfoChange?(nil) + } } - private func forceStop() { + private func forceStop(pendingError: Error) { shutdownFallbackTask?.cancel() shutdownFallbackTask = nil + failPendingRequests(with: pendingError) + isStopping = true + if !isFailed { transition(to: .stopping) } process.stop() resetTransientState() + if !isFailed { transition(to: .stopped) } + } + + private var isFailed: Bool { + if case .failed = state { return true } + return false + } + + private func transition(to updatedState: LanguageServerSessionState) { + guard state != updatedState else { return } + state = updatedState + onStateChange?(updatedState) + } + + private func failSession( + _ error: Error, + exitCode: Int32? = nil, + stopProcess: Bool = true + ) { + guard !isFailed else { return } + initializeTimeoutTask?.cancel() + initializeTimeoutTask = nil + failPendingRequests(with: error) + let message = error.localizedDescription + transition(to: .failed(exitCode: exitCode, message: message)) + onLog?(.error, "Language server session failed", message) + if stopProcess, process.isRunning { + process.stop() + } + resetTransientState() + } + + private func failPendingRequests(with error: Error) { + let pending = responseHandlers + responseHandlers = [:] + let timeoutTasks = responseTimeoutTasks.values + responseTimeoutTasks = [:] + timeoutTasks.forEach { $0.cancel() } + for response in pending.values { + response.completion(.failure(error)) + } + } + + private func handleInitialize(_ event: RustCoreBridge.LspClientEventPayload) throws { + let result = try validatedInitializeResult(event) + initializeTimeoutTask?.cancel() + initializeTimeoutTask = nil + isInitialized = true + let initializedServerInfo = Self.serverInfo(from: result["serverInfo"]) + transition(to: .ready) + if initializedServerInfo != serverInfo { + serverInfo = initializedServerInfo + onServerInfoChange?(initializedServerInfo) + } + onLog?(.info, "Language server is ready", initializedServerInfo?.name) + try flushPendingDocuments() + } + + private func validateInitializeEvents( + _ events: [RustCoreBridge.LspClientEventPayload] + ) throws { + for event in events where event.method == "initialize" { + _ = try validatedInitializeResult(event) + } + } + + private func validatedInitializeResult( + _ event: RustCoreBridge.LspClientEventPayload + ) throws -> [String: ToolingJSONValue] { + if event.kind == "error" || event.error != nil { + throw StdioLanguageServerSessionError.initializeFailed( + event.error ?? "Language server rejected initialize." + ) + } + guard event.kind == "response", + case .object(let result)? = event.result, + case .object = result["capabilities"] else { + throw StdioLanguageServerSessionError.invalidInitializeResult + } + return result + } + + private func scheduleInitializeTimeout() { + initializeTimeoutTask?.cancel() + let timeout = initializeTimeoutNanoseconds + initializeTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: timeout) + } catch { + return + } + guard let self, !self.isInitialized, !self.isStopping, !self.isFailed else { return } + self.failSession(StdioLanguageServerSessionError.initializeTimedOut) + } + } + + private func scheduleRequestTimeout(requestID: String) { + responseTimeoutTasks[requestID]?.cancel() + let timeout = requestTimeoutNanoseconds + responseTimeoutTasks[requestID] = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: timeout) + } catch { + return + } + guard let self, + let pending = self.responseHandlers.removeValue(forKey: requestID) else { return } + self.responseTimeoutTasks[requestID] = nil + let timeoutError = StdioLanguageServerSessionError.requestTimedOut( + method: pending.method, + fileURL: pending.fileURL + ) + var transportError: Error? + do { + try self.sendCancellation(requestID: requestID) + } catch { + transportError = error + } + pending.completion(.failure(timeoutError)) + if let transportError { + self.failSession(transportError) + } + } + } + + private func sendCancellation(requestID: String) throws { + let object: [String: Any] = [ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": ["id": requestID] + ] + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } catch { + throw StdioLanguageServerSessionError.protocolFailure( + "Could not encode cancellation for LSP request \(requestID)." + ) + } + guard let message = String(data: data, encoding: .utf8) else { + throw StdioLanguageServerSessionError.protocolFailure( + "Could not encode cancellation for LSP request \(requestID)." + ) + } + try sendRawJSON(message) + } + + private static func serverInfo(from value: ToolingJSONValue?) -> LanguageServerInfo? { + guard case .object(let object)? = value, + case .string(let name)? = object["name"], + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + let version: String? + if case .string(let value)? = object["version"], !value.isEmpty { + version = value + } else { + version = nil + } + return LanguageServerInfo(name: name, version: version) } private func updateFeatures(from state: ToolingJSONValue) { @@ -728,11 +1066,27 @@ final class StdioLanguageServerSession: LanguageServerSession { let languageID: String } + private struct PendingResponse { + let method: String + let fileURL: URL + let completion: (Result) -> Void + } + private enum StdioLanguageServerSessionError: LocalizedError { case notReady case documentNotOpen case requestRejected + case missingRequestID case missingResult + case coreRejected(String) + case initializeFailed(String) + case invalidInitializeResult + case initializeTimedOut + case transportFailure(String) + case protocolFailure(String) + case requestTimedOut(method: String, fileURL: URL) + case sessionTerminated(Int32) + case sessionStopped case serverError(String) var errorDescription: String? { @@ -743,8 +1097,28 @@ final class StdioLanguageServerSession: LanguageServerSession { "Document is not open in the language server." case .requestRejected: "Language server request was rejected by Rust core." + case .missingRequestID: + "Language server request did not include a JSON-RPC request ID." case .missingResult: "Language server response did not include a result." + case .coreRejected(let operation): + "Rust core rejected the LSP \(operation) operation." + case .initializeFailed(let message): + "Language server initialize failed: \(message)" + case .invalidInitializeResult: + "Language server initialize returned an invalid result." + case .initializeTimedOut: + "Language server initialize timed out." + case .transportFailure(let message): + "Language server transport failed: \(message)" + case .protocolFailure(let message): + "Language server protocol failed: \(message)" + case .requestTimedOut(let method, let fileURL): + "Language server request \(method) timed out for \(fileURL.lastPathComponent)." + case .sessionTerminated(let exitCode): + "Language server terminated unexpectedly with exit code \(exitCode)." + case .sessionStopped: + "Language server session stopped before the request completed." case .serverError(let message): message } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 562afc78..dd8958a9 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1079,15 +1079,223 @@ struct RunConfigurationIntegrationTests { text: "struct App {}\n", rootURL: source.deletingLastPathComponent() ) + #expect(manager.activeLanguageServerIDs.isEmpty) + #expect(manager.languageServerStates["swift"] == .initializing) + + process.emitJSON([ + "jsonrpc": "2.0", + "id": "1", + "result": ["capabilities": [:]] + ]) + await Self.drainMainActorTasks() + #expect(manager.activeLanguageServerIDs == ["swift"]) - #expect(manager.languageServerStates["swift"] == .running) + #expect(manager.languageServerStates["swift"] == .ready) process.terminate(exitCode: 1) await Self.drainMainActorTasks() #expect(manager.activeLanguageServerIDs.isEmpty) #expect(manager.languageServerFeatures["swift"] == nil) - #expect(manager.languageServerStates["swift"] == .failed(exitCode: 1, message: nil)) + guard case .failed(let exitCode, let message)? = manager.languageServerStates["swift"] else { + Issue.record("Expected the Swift language server to fail after process termination") + return + } + #expect(exitCode == 1) + #expect(message?.contains("exit code 1") == true) + } + + @Test + func initializeErrorNeverActivatesLanguageServer() async throws { + let harness = makeLanguageServerHarness() + + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App {}\n", + rootURL: harness.root + ) + #expect(harness.manager.languageServerStates["swift"] == .initializing) + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + + harness.process.emitJSON([ + "jsonrpc": "2.0", + "id": "1", + "error": ["code": -32603, "message": "initialize rejected"] + ]) + await Self.drainMainActorTasks() + + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + #expect(!harness.process.isRunning) + guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else { + Issue.record("Expected initialize error to fail the language server") + return + } + #expect(message?.contains("initialize failed") == true) + #expect(!Self.framedMessages(harness.process.sentData).contains { + $0.contains("textDocument/didOpen") + }) + } + + @Test + func invalidInitializeResultNeverMarksLanguageServerReady() async throws { + let harness = makeLanguageServerHarness() + + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App {}\n", + rootURL: harness.root + ) + harness.process.emitJSON([ + "jsonrpc": "2.0", + "id": "1", + "result": NSNull() + ]) + await Self.drainMainActorTasks() + + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + #expect(!harness.process.isRunning) + guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else { + Issue.record("Expected invalid initialize result to fail the language server") + return + } + #expect(message?.contains("invalid result") == true) + } + + @Test + func initializeTimeoutTransitionsInitializingSessionToFailed() async throws { + let harness = makeLanguageServerHarness(initializeTimeoutNanoseconds: 2_000_000) + + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App {}\n", + rootURL: harness.root + ) + #expect(harness.manager.languageServerStates["swift"] == .initializing) + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + + try await Task.sleep(nanoseconds: 20_000_000) + await Self.drainMainActorTasks() + + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + #expect(!harness.process.isRunning) + guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else { + Issue.record("Expected initialize timeout to fail the language server") + return + } + #expect(message?.contains("initialize timed out") == true) + } + + @Test + func pendingCompletionFailsExactlyOnceWhenServerTerminates() async throws { + let harness = makeLanguageServerHarness() + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App { let title = 1 }\n", + rootURL: harness.root + ) + Self.emitSuccessfulInitialize(on: harness.process) + await Self.drainMainActorTasks() + + var completionResult: Result<[LanguageServerCompletionItem], Error>? + var completionCount = 0 + try harness.manager.completions( + fileURL: harness.source, + text: "struct App { let ti = 1 }\n", + position: LanguageServerPosition(line: 0, utf16Column: 19), + rootURL: harness.root + ) { result in + completionCount += 1 + completionResult = result + } + + harness.process.terminate(exitCode: 9) + await Self.drainMainActorTasks() + + #expect(completionCount == 1) + guard case .failure(let error)? = completionResult else { + Issue.record("Expected pending completion to fail when the server terminates") + return + } + #expect(error.localizedDescription.contains("exit code 9")) + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + } + + @Test + func requestTimeoutSendsCancellationAndReturnsFailure() async throws { + let harness = makeLanguageServerHarness(requestTimeoutNanoseconds: 2_000_000) + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App { let title = 1 }\n", + rootURL: harness.root + ) + Self.emitSuccessfulInitialize(on: harness.process) + await Self.drainMainActorTasks() + + var completionResult: Result<[LanguageServerCompletionItem], Error>? + try harness.manager.completions( + fileURL: harness.source, + text: "struct App { let ti = 1 }\n", + position: LanguageServerPosition(line: 0, utf16Column: 19), + rootURL: harness.root + ) { completionResult = $0 } + + try await Task.sleep(nanoseconds: 20_000_000) + await Self.drainMainActorTasks() + + guard case .failure(let error)? = completionResult else { + Issue.record("Expected completion timeout to return failure") + return + } + #expect(error.localizedDescription.contains("timed out")) + #expect(harness.process.sentData.compactMap(Self.framedJSON).contains { message in + message["method"] as? String == "$/cancelRequest" + && (message["params"] as? [String: Any])?["id"] as? String == "2" + }) + #expect(harness.manager.languageServerStates["swift"] == .ready) + } + + @Test + func didOpenWriteFailureFailsSessionAndRetriesWithFullOpen() async throws { + let harness = makeLanguageServerHarness() + harness.process.sendFailurePredicate = { data in + String(decoding: data, as: UTF8.self).contains("textDocument/didOpen") + } + + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App {}\n", + rootURL: harness.root + ) + Self.emitSuccessfulInitialize(on: harness.process) + await Self.drainMainActorTasks() + + #expect(harness.manager.activeLanguageServerIDs.isEmpty) + #expect(!harness.process.isRunning) + guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else { + Issue.record("Expected didOpen transport failure to fail the session") + return + } + #expect(message?.contains("transport failed") == true) + #expect(!Self.framedMessages(harness.process.sentData).contains { + $0.contains("textDocument/didOpen") + }) + + harness.process.sendFailurePredicate = nil + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App { let retried = true }\n", + rootURL: harness.root + ) + Self.emitSuccessfulInitialize(on: harness.process) + await Self.drainMainActorTasks() + + let openMessages = Self.framedMessages(harness.process.sentData).filter { + $0.contains("textDocument/didOpen") + } + #expect(openMessages.count == 1) + #expect(openMessages[0].contains(#""version":1"#)) + #expect(openMessages[0].contains("retried")) + #expect(harness.manager.languageServerStates["swift"] == .ready) } @Test @@ -1140,7 +1348,8 @@ struct RunConfigurationIntegrationTests { #expect(startRequest.environment?["SOURCEKIT_TOOLCHAIN"] == "custom") #expect(initializationRecorder.options == .object(["indexing": .bool(true)])) #expect(initializationRecorder.actions == ["create"]) - #expect(manager.activeLanguageServerIDs == ["swift"]) + #expect(manager.activeLanguageServerIDs.isEmpty) + #expect(manager.languageServerStates["swift"] == .initializing) let firstFrameData = try #require(process.sentData.first) let firstFrame = try #require(String(data: firstFrameData, encoding: .utf8)) #expect(firstFrame.hasPrefix("Content-Length: ")) @@ -1154,10 +1363,20 @@ struct RunConfigurationIntegrationTests { "capabilities": [ "hoverProvider": true, "completionProvider": [:] + ], + "serverInfo": [ + "name": "sourcekit-lsp", + "version": "6.2" ] ] ]) await Self.drainMainActorTasks() + #expect(manager.activeLanguageServerIDs == ["swift"]) + #expect(manager.languageServerStates["swift"] == .ready) + #expect(manager.languageServerInfos["swift"] == LanguageServerInfo( + name: "sourcekit-lsp", + version: "6.2" + )) #expect(manager.languageServerFeatures["swift"]?.contains(.completion) == true) #expect(manager.languageServerFeatures["swift"]?.contains(.hover) == true) #expect(initializationRecorder.actions.contains("applyServerMessage")) @@ -2845,6 +3064,72 @@ struct RunConfigurationIntegrationTests { return try? JSONSerialization.jsonObject(with: body) as? [String: Any] } + private func makeLanguageServerHarness( + initializeTimeoutNanoseconds: UInt64 = 10_000_000_000, + requestTimeoutNanoseconds: UInt64 = 30_000_000_000 + ) -> LanguageServerReliabilityHarness { + let descriptor = LanguageProviderDescriptor( + id: "swift", + displayName: "Swift", + fileExtensions: ["swift"], + capabilities: [.languageServer, .formatting], + activationPolicy: .onDemand, + languageIdentifier: "swift" + ) + let root = URL(fileURLWithPath: "/tmp/lithe-lsp-reliability", isDirectory: true) + let source = root.appendingPathComponent("App.swift") + let process = RecordingRawProcessSession() + let initializationRecorder = TestLspInitializationRecorder() + let session = StdioLanguageServerSession( + executableURL: URL(fileURLWithPath: "/usr/bin/sourcekit-lsp"), + arguments: [], + environment: [:], + initializeTimeoutNanoseconds: initializeTimeoutNanoseconds, + requestTimeoutNanoseconds: requestTimeoutNanoseconds, + process: process, + core: TestLspClientCore( + diagnosticURL: source, + initializationRecorder: initializationRecorder + ) + ) + let runtime = TestLanguageServerRuntime(descriptor: descriptor, session: session) + let manager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimes: [runtime] + ) + return LanguageServerReliabilityHarness( + root: root, + source: source, + process: process, + session: session, + manager: manager + ) + } + + private static func emitSuccessfulInitialize(on process: RecordingRawProcessSession) { + process.emitJSON([ + "jsonrpc": "2.0", + "id": "1", + "result": [ + "capabilities": [ + "hoverProvider": true, + "completionProvider": [:] + ], + "serverInfo": [ + "name": "sourcekit-lsp", + "version": "6.2" + ] + ] + ]) + } + + private static func framedMessages(_ frames: [Data]) -> [String] { + frames.compactMap { data in + guard let separator = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } + return String(decoding: data[separator.upperBound...], as: UTF8.self) + } + } + private static func debugRequest(named command: String, in frames: [Data]) -> [String: Any]? { frames.lazy .compactMap(framedJSON) @@ -2859,6 +3144,15 @@ struct RunConfigurationIntegrationTests { } } +@MainActor +private struct LanguageServerReliabilityHarness { + let root: URL + let source: URL + let process: RecordingRawProcessSession + let session: StdioLanguageServerSession + let manager: LanguageToolingSessionManager +} + @MainActor private struct RunServiceFixture { let root: URL @@ -3162,6 +3456,13 @@ private struct TestLspClientCore: LspClientCore, LspSessionCore { state _: ToolingJSONValue, message: String ) -> RustCoreBridge.LspClientResponsePayload? { + if let data = message.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["id"] as? String == "1" { + let result = object["result"].flatMap(ToolingJSONValue.fromFoundation) + let error = object["error"].map { String(describing: $0) } + return initializeResponse(result: result, error: error) + } if message.contains("publishDiagnostics") { return response(events: [ RustCoreBridge.LspClientEventPayload( @@ -3373,7 +3674,23 @@ private struct TestLspClientCore: LspClientCore, LspSessionCore { ] ) } - return response( + return initializeResponse( + result: .object([ + "capabilities": .object([:]), + "serverInfo": .object([ + "name": .string("sourcekit-lsp"), + "version": .string("6.2") + ]) + ]), + error: nil + ) + } + + private func initializeResponse( + result: ToolingJSONValue?, + error: String? + ) -> RustCoreBridge.LspClientResponsePayload { + response( state: .object([ "serverCapabilities": .array([ .string("hover"), @@ -3386,16 +3703,18 @@ private struct TestLspClientCore: LspClientCore, LspSessionCore { .string("executeCommand") ]) ]), - messages: [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#], + messages: error == nil && result != nil + ? [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#] + : [], events: [ RustCoreBridge.LspClientEventPayload( - kind: "response", + kind: error == nil ? "response" : "error", requestId: "1", method: "initialize", uri: nil, diagnostics: nil, - result: nil, - error: nil + result: result, + error: error ) ] ) @@ -3580,12 +3899,18 @@ private final class RecordingRawProcessSession: RawProcessSession, @unchecked Se var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? private(set) var requests: [ProcessRequest] = [] private(set) var sentData: [Data] = [] + var sendFailurePredicate: ((Data) -> Bool)? func start(_ request: ProcessRequest) throws { requests.append(request) isRunning = true } - func send(_ input: Data) throws { sentData.append(input) } + func send(_ input: Data) throws { + if sendFailurePredicate?(input) == true { + throw RecordingRawProcessSessionError.sendFailed + } + sentData.append(input) + } func stop() { isRunning = false } func terminate(exitCode: Int32) { @@ -3612,6 +3937,12 @@ private final class RecordingRawProcessSession: RawProcessSession, @unchecked Se } } +private enum RecordingRawProcessSessionError: LocalizedError { + case sendFailed + + var errorDescription: String? { "Injected language server transport write failure." } +} + @MainActor private final class RecordingDebugAdapterTransport: DebugAdapterTransport, DebugAdapterChildTransportProviding { private(set) var isRunning = false @@ -3794,6 +4125,21 @@ private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime { } } +@MainActor +private final class TestLanguageServerRuntime: LanguageProviderRuntime { + let descriptor: LanguageProviderDescriptor + let supportsLanguageServerSession = true + private let session: any LanguageServerSession + + init(descriptor: LanguageProviderDescriptor, session: any LanguageServerSession) { + self.descriptor = descriptor + self.session = session + } + + func makeLanguageServerSession() -> (any LanguageServerSession)? { session } + func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil } +} + @MainActor private final class TestLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { private(set) var createdDescriptors: [LanguageProviderDescriptor] = [] diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index d08d5ea3..a5bb002c 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -257,7 +257,11 @@ pub fn client_apply_server_message( } else if let Some(id) = lsp_message_id(&message) { let pending = state.pending_requests.remove(&id); if pending.as_deref() == Some("initialize") { - if let Some(result) = message.get("result") { + if let Some(result) = message + .get("result") + .and_then(Value::as_object) + .filter(|result| result.get("capabilities").is_some_and(Value::is_object)) + { state.server_capabilities = feature_names_from_capabilities( result.get("capabilities").unwrap_or(&Value::Null), ); diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 17b193c2..fc43ff03 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -470,6 +470,32 @@ fn client_core_initializes_and_applies_server_capabilities() { assert_eq!(initialized_notification["method"], "initialized"); } +#[test] +fn client_core_does_not_initialize_without_valid_capabilities() { + for message in [ + r#"{"jsonrpc":"2.0","id":"1","result":null}"#, + r#"{"jsonrpc":"2.0","id":"1","result":{}}"#, + r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"rejected"}}"#, + ] { + let initialized = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: "file:///tmp/project".to_string(), + process_id: None, + initialization_options: None, + }) + .unwrap(); + let applied = client_apply_server_message(ClientApplyServerMessageRequest { + state: initialized.state, + message: message.to_string(), + }) + .unwrap(); + + assert!(!applied.state.initialized); + assert!(applied.messages.is_empty()); + assert!(applied.state.server_capabilities.is_empty()); + } +} + #[test] fn client_core_tracks_documents_and_feature_requests() { let opened = client_open_document(ClientOpenDocumentRequest { From ce75b14023783e04d50e1fa0bda71fabc9cad2cd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:29:31 +0800 Subject: [PATCH 33/38] Distinguish executable verification from LSP readiness --- .../Services/LanguageServerToolService.swift | 28 +++++++++- .../Lithe/Views/LanguageServerSetupView.swift | 38 +++++++++++-- .../LanguageServerToolServiceTests.swift | 53 +++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/Sources/Lithe/Services/LanguageServerToolService.swift b/Sources/Lithe/Services/LanguageServerToolService.swift index f583ba59..48715bc0 100644 --- a/Sources/Lithe/Services/LanguageServerToolService.swift +++ b/Sources/Lithe/Services/LanguageServerToolService.swift @@ -37,6 +37,12 @@ enum LanguageServerInstallationState: Equatable, Sendable { case failed(String) } +enum LanguageServerExecutableVerificationState: Equatable, Sendable { + case unavailable + case foundUnverified + case executableVerified +} + enum LanguageServerToolConfigurationError: LocalizedError, Equatable { case executableRequired case executableInvalid(String) @@ -129,6 +135,17 @@ final class LanguageServerToolService: ObservableObject { candidates(for: descriptor).first?.executableURL } + func executableVerificationState( + for descriptor: LanguageProviderDescriptor + ) -> LanguageServerExecutableVerificationState { + guard let candidate = candidates(for: descriptor).first else { + return .unavailable + } + return validate(candidate, for: descriptor).didExecute + ? .executableVerified + : .foundUnverified + } + func setCustomExecutablePath( _ path: String, for descriptor: LanguageProviderDescriptor @@ -212,7 +229,7 @@ final class LanguageServerToolService: ObservableObject { for descriptor: LanguageProviderDescriptor ) -> ExecutableValidationResult { let arguments = descriptor.languageServerLaunch?.validationArguments ?? [] - guard !arguments.isEmpty else { return .usable } + guard !arguments.isEmpty else { return .unverifiedUsable } let key = ExecutableValidationKey( executablePath: candidate.executableURL.standardizedFileURL.path, arguments: arguments @@ -232,6 +249,7 @@ final class LanguageServerToolService: ObservableObject { let validation = ExecutableValidationResult( isUsable: result.succeeded, message: output.isEmpty ? "Exited with code \(result.exitCode)." : output, + didExecute: true, checkedAt: Date() ) validationCache[key] = validation @@ -247,9 +265,15 @@ private struct ExecutableValidationKey: Hashable { private struct ExecutableValidationResult { let isUsable: Bool let message: String + let didExecute: Bool let checkedAt: Date - static let usable = Self(isUsable: true, message: "", checkedAt: .distantFuture) + static let unverifiedUsable = Self( + isUsable: true, + message: "", + didExecute: false, + checkedAt: .distantFuture + ) } private struct LanguageServerToolSettingsStore { diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/LanguageServerSetupView.swift index c5a5db75..a1a15393 100644 --- a/Sources/Lithe/Views/LanguageServerSetupView.swift +++ b/Sources/Lithe/Views/LanguageServerSetupView.swift @@ -54,6 +54,11 @@ struct LanguageServerSetupView: View { candidates.first } + private var executableVerificationState: LanguageServerExecutableVerificationState { + guard let selectedDescriptor else { return .unavailable } + return tools.executableVerificationState(for: selectedDescriptor) + } + private var installPlan: LanguageServerInstallPlan? { selectedDescriptor.map(tools.installPlan(for:)) } @@ -128,11 +133,11 @@ struct LanguageServerSetupView: View { sectionTitle(copy.detectedExecutable) HStack(alignment: .top, spacing: 9) { Circle() - .fill(resolvedExecutable == nil ? LitheTheme.warning : LitheTheme.success) + .fill(executableStatusColor) .frame(width: 8, height: 8) .padding(.top, 4) VStack(alignment: .leading, spacing: 3) { - Text(resolvedExecutable == nil ? copy.notFound : copy.ready) + Text(executableStatusTitle) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) Text(resolvedExecutable?.executableURL.path ?? expectedCommands) @@ -289,6 +294,28 @@ struct LanguageServerSetupView: View { return copy.expectedCommands(commands.joined(separator: ", ")) } + private var executableStatusTitle: String { + switch executableVerificationState { + case .unavailable: + copy.notFound + case .foundUnverified: + copy.executableFoundUnverified + case .executableVerified: + copy.executableVerified + } + } + + private var executableStatusColor: Color { + switch executableVerificationState { + case .unavailable: + LitheTheme.warning + case .foundUnverified: + LitheTheme.accent + case .executableVerified: + LitheTheme.success + } + } + private var canInstallWithHomebrew: Bool { guard installPlan?.homebrewFormula != nil, tools.isHomebrewAvailable() else { return false } @@ -352,7 +379,12 @@ private struct LanguageServerSetupCopy { var subtitle: String { usesChinese ? "安装、探测并指定 LSP 可执行文件" : "Install, detect, and select LSP executables" } var languageServer: String { usesChinese ? "语言服务器" : "Language server" } var detectedExecutable: String { usesChinese ? "当前解析结果" : "Resolved executable" } - var ready: String { usesChinese ? "可用" : "Ready" } + var executableFoundUnverified: String { + usesChinese ? "已找到可执行文件(未验证)" : "Executable found (not verified)" + } + var executableVerified: String { + usesChinese ? "可执行文件已验证" : "Executable verified" + } var notFound: String { usesChinese ? "未找到可执行文件" : "Executable not found" } var executablePath: String { usesChinese ? "自定义路径" : "Custom path" } var useAutomatic: String { usesChinese ? "恢复自动探测" : "Use automatic detection" } diff --git a/Tests/LitheTests/LanguageServerToolServiceTests.swift b/Tests/LitheTests/LanguageServerToolServiceTests.swift index 893c1723..b21d80fb 100644 --- a/Tests/LitheTests/LanguageServerToolServiceTests.swift +++ b/Tests/LitheTests/LanguageServerToolServiceTests.swift @@ -124,6 +124,59 @@ struct LanguageServerToolServiceTests { } } + @Test + func reportsFoundButUnverifiedWhenCatalogHasNoValidationCommand() { + let executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/gopls") + let store = LanguageServerToolTestStore() + let runner = LanguageServerToolTestProcessRunner() + let service = LanguageServerToolService( + runtimeService: makeRuntime( + executablePaths: [executableURL.path], + candidates: [ + "gopls": [RuntimeToolCandidate( + command: "gopls", + executableURL: executableURL, + source: .homebrew + )] + ], + store: store + ), + processRunner: runner, + store: store + ) + + #expect(service.executableVerificationState(for: goDescriptor()) == .foundUnverified) + #expect(runner.requests.isEmpty) + } + + @Test + func reportsExecutableVerifiedOnlyAfterValidationCommandSucceeds() { + let executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/rust-analyzer") + let store = LanguageServerToolTestStore() + let runner = LanguageServerToolTestProcessRunner( + result: ProcessResult(output: "rust-analyzer 1.0.0", exitCode: 0) + ) + let service = LanguageServerToolService( + runtimeService: makeRuntime( + executablePaths: [executableURL.path], + candidates: [ + "rust-analyzer": [RuntimeToolCandidate( + command: "rust-analyzer", + executableURL: executableURL, + source: .homebrew + )] + ], + store: store + ), + processRunner: runner, + store: store + ) + + #expect(service.executableVerificationState(for: rustDescriptor()) == .executableVerified) + #expect(runner.requests.count == 1) + #expect(runner.requests.first?.arguments == ["--version"]) + } + @Test func installsVerifiedFormulaWithArgumentBasedProcessRequest() async throws { let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") From 3d3f786e90bb85bb3a68906210e5663ef4afd6a0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:30:44 +0800 Subject: [PATCH 34/38] Preserve LSP diagnostic metadata without false errors --- Sources/Lithe/Core/RustCoreBridge.swift | 80 ++++++++++--- .../Lithe/Models/JavaDiagnosticModels.swift | 16 ++- Sources/Lithe/Views/CodeEditorView.swift | 1 + Sources/Lithe/Views/JavaProblemsView.swift | 1 + .../Lithe/Views/LSPControlCenterView.swift | 2 + .../LanguageServerDiagnosticTests.swift | 107 ++++++++++++++++++ rust/lithe-core/src/lsp/interface/client.rs | 72 ++++++++++-- rust/lithe-core/src/lsp/interface/types.rs | 18 +++ rust/lithe-core/src/lsp/tests.rs | 79 ++++++++++++- 9 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 Tests/LitheTests/LanguageServerDiagnosticTests.swift diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 90a5f77d..3983c455 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -1050,6 +1050,26 @@ struct RustCoreBridge: Sendable { let message: String let source: String? let code: String? + let tags: [Int]? + let relatedInformation: [LspClientDiagnosticRelatedInformationPayload]? + + init( + range: LspRangePayload, + severity: Int?, + message: String, + source: String?, + code: String?, + tags: [Int]? = nil, + relatedInformation: [LspClientDiagnosticRelatedInformationPayload]? = nil + ) { + self.range = range + self.severity = severity + self.message = message + self.source = source + self.code = code + self.tags = tags + self.relatedInformation = relatedInformation + } func makeModel() -> LanguageServerDiagnostic { LanguageServerDiagnostic( @@ -1057,11 +1077,32 @@ struct RustCoreBridge: Sendable { severity: severity, message: message, source: source, - code: code + code: code, + tags: tags ?? [], + relatedInformation: (relatedInformation ?? []).compactMap { $0.makeModel() } ) } } + struct LspClientDiagnosticRelatedInformationPayload: Decodable, Sendable { + let location: LspClientDiagnosticLocationPayload + let message: String + + func makeModel() -> LanguageServerDiagnosticRelatedInformation? { + guard let fileURL = URL(string: location.uri) else { return nil } + return LanguageServerDiagnosticRelatedInformation( + fileURL: fileURL.standardizedFileURL, + range: location.range.makeModel(), + message: message + ) + } + } + + struct LspClientDiagnosticLocationPayload: Decodable, Sendable { + let uri: String + let range: LspRangePayload + } + private struct LspClientInitializeRequest: Encodable { let state: ToolingJSONValue? let rootUri: String @@ -1110,6 +1151,18 @@ struct RustCoreBridge: Sendable { let message: String let source: String? let code: String? + let tags: [Int] + let relatedInformation: [LspClientDiagnosticRelatedInformationRequest] + } + + private struct LspClientDiagnosticRelatedInformationRequest: Encodable { + let location: LspClientDiagnosticLocationRequest + let message: String + } + + private struct LspClientDiagnosticLocationRequest: Encodable { + let uri: String + let range: LspTextEditsRequest.TextEdit.Range } private struct LspClientCompletionItemRequest: Encodable { @@ -2384,18 +2437,7 @@ struct RustCoreBridge: Sendable { end: .init(line: $0.end.line, utf16Column: $0.end.utf16Column) ) }, - diagnostics: diagnostics.map { - LspClientDiagnosticRequest( - range: .init( - start: .init(line: $0.range.start.line, utf16Column: $0.range.start.utf16Column), - end: .init(line: $0.range.end.line, utf16Column: $0.range.end.utf16Column) - ), - severity: $0.severity, - message: $0.message, - source: $0.source, - code: $0.code - ) - }, + diagnostics: diagnostics.map(Self.makeDiagnosticRequest), completionItem: completionItem.map(Self.makeCompletionItemRequest), codeAction: codeAction.map(Self.makeCodeActionRequest), command: command.map(Self.makeCommandRequest) @@ -2443,7 +2485,17 @@ struct RustCoreBridge: Sendable { severity: diagnostic.severity, message: diagnostic.message, source: diagnostic.source, - code: diagnostic.code + code: diagnostic.code, + tags: diagnostic.tags, + relatedInformation: diagnostic.relatedInformation.map { + LspClientDiagnosticRelatedInformationRequest( + location: LspClientDiagnosticLocationRequest( + uri: $0.fileURL.standardizedFileURL.absoluteString, + range: makeRangeRequest($0.range) + ), + message: $0.message + ) + } ) } diff --git a/Sources/Lithe/Models/JavaDiagnosticModels.swift b/Sources/Lithe/Models/JavaDiagnosticModels.swift index 7cbd95a8..861592f3 100644 --- a/Sources/Lithe/Models/JavaDiagnosticModels.swift +++ b/Sources/Lithe/Models/JavaDiagnosticModels.swift @@ -1,6 +1,7 @@ import Foundation enum DiagnosticSeverity: Int, CaseIterable, Hashable, Sendable { + case unknown = 0 case error = 1 case warning = 2 case information = 3 @@ -8,6 +9,7 @@ enum DiagnosticSeverity: Int, CaseIterable, Hashable, Sendable { var title: String { switch self { + case .unknown: "Unknown" case .error: "Error" case .warning: "Warning" case .information: "Information" @@ -17,6 +19,7 @@ enum DiagnosticSeverity: Int, CaseIterable, Hashable, Sendable { var systemImage: String { switch self { + case .unknown: "questionmark.circle.fill" case .error: "xmark.octagon.fill" case .warning: "exclamationmark.triangle.fill" case .information: "info.circle.fill" @@ -134,12 +137,19 @@ struct EditorDiagnostic: Identifiable, Hashable, Sendable { utf16Column: column, endLine: endLine, endUTF16Column: endColumn, - severity: DiagnosticSeverity(rawValue: diagnostic.severity ?? 1) ?? .error, + severity: diagnostic.severity.flatMap(DiagnosticSeverity.init(rawValue:)) ?? .unknown, message: diagnostic.message, source: diagnostic.source, code: diagnostic.code, - tags: [], - relatedInformation: [] + tags: Set(diagnostic.tags.compactMap(DiagnosticTag.init(rawValue:))), + relatedInformation: diagnostic.relatedInformation.map { + DiagnosticRelatedInformation( + fileURL: $0.fileURL.standardizedFileURL, + line: max(0, $0.range.start.line), + utf16Column: max(0, $0.range.start.utf16Column), + message: $0.message + ) + } ) } diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 22ec01e5..174ff41c 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -1491,6 +1491,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate { private func diagnosticColor(for severity: JavaDiagnosticSeverity) -> NSColor { switch severity { + case .unknown: NSColor.systemGray case .error: NSColor.systemRed case .warning: NSColor.systemOrange case .information: NSColor.systemBlue diff --git a/Sources/Lithe/Views/JavaProblemsView.swift b/Sources/Lithe/Views/JavaProblemsView.swift index edbf43bb..a6c4b056 100644 --- a/Sources/Lithe/Views/JavaProblemsView.swift +++ b/Sources/Lithe/Views/JavaProblemsView.swift @@ -180,6 +180,7 @@ struct ProblemsView: View { private func color(for severity: DiagnosticSeverity) -> Color { switch severity { + case .unknown: LitheTheme.secondaryText case .error: LitheTheme.error case .warning: LitheTheme.warning case .information: LitheTheme.accent diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index fcef9e4a..461e26de 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -587,6 +587,7 @@ struct LSPControlCenterView: View { private func color(for severity: DiagnosticSeverity) -> Color { switch severity { + case .unknown: LitheTheme.secondaryText case .error: LitheTheme.error case .warning: LitheTheme.warning case .information: LitheTheme.accent @@ -935,6 +936,7 @@ private extension DiagnosticSeverity { case .warning: 1 case .information: 2 case .hint: 3 + case .unknown: 4 } } } diff --git a/Tests/LitheTests/LanguageServerDiagnosticTests.swift b/Tests/LitheTests/LanguageServerDiagnosticTests.swift new file mode 100644 index 00000000..a3f2273c --- /dev/null +++ b/Tests/LitheTests/LanguageServerDiagnosticTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Language server diagnostics") +struct LanguageServerDiagnosticTests { + @Test + func bridgePreservesMissingSeverityTagsAndRelatedInformation() throws { + let payload = try JSONDecoder().decode( + RustCoreBridge.LspClientDiagnosticPayload.self, + from: Data(#""" + { + "range": { + "start": { "line": 2, "utf16Column": 4 }, + "end": { "line": 2, "utf16Column": 9 } + }, + "message": "Example diagnostic", + "source": "pyright", + "code": "reportGeneralTypeIssues", + "tags": [1, 2, 99], + "relatedInformation": [{ + "location": { + "uri": "file:///tmp/project/types.py", + "range": { + "start": { "line": 8, "utf16Column": 2 }, + "end": { "line": 8, "utf16Column": 7 } + } + }, + "message": "Type declared here" + }] + } + """#.utf8) + ) + + let diagnostic = payload.makeModel() + + #expect(diagnostic.severity == nil) + #expect(diagnostic.tags == [1, 2, 99]) + #expect(diagnostic.relatedInformation.count == 1) + #expect(diagnostic.relatedInformation[0].fileURL.path == "/tmp/project/types.py") + #expect(diagnostic.relatedInformation[0].range.start.line == 8) + #expect(diagnostic.relatedInformation[0].range.start.utf16Column == 2) + #expect(diagnostic.relatedInformation[0].message == "Type declared here") + } + + @Test + func editorDiagnosticMapsMissingSeverityToUnknownAndKnownTagsToUIModels() { + let fileURL = URL(fileURLWithPath: "/tmp/project/main.py") + let relatedURL = URL(fileURLWithPath: "/tmp/project/types.py") + let diagnostic = LanguageServerDiagnostic( + range: LanguageServerRange( + start: LanguageServerPosition(line: 2, utf16Column: 4), + end: LanguageServerPosition(line: 2, utf16Column: 9) + ), + severity: nil, + message: "Example diagnostic", + source: "pyright", + code: "reportGeneralTypeIssues", + tags: [1, 2, 99], + relatedInformation: [ + LanguageServerDiagnosticRelatedInformation( + fileURL: relatedURL, + range: LanguageServerRange( + start: LanguageServerPosition(line: 8, utf16Column: 2), + end: LanguageServerPosition(line: 8, utf16Column: 7) + ), + message: "Type declared here" + ) + ] + ) + + let editorDiagnostic = EditorDiagnostic( + languageServerDiagnostic: diagnostic, + fileURL: fileURL + ) + + #expect(editorDiagnostic.severity == .unknown) + #expect(editorDiagnostic.tags == [.unnecessary, .deprecated]) + #expect(editorDiagnostic.relatedInformation.count == 1) + #expect(editorDiagnostic.relatedInformation[0].fileURL == relatedURL.standardizedFileURL) + #expect(editorDiagnostic.relatedInformation[0].line == 8) + #expect(editorDiagnostic.relatedInformation[0].utf16Column == 2) + #expect(editorDiagnostic.relatedInformation[0].message == "Type declared here") + } + + @Test(arguments: [Optional.none, .some(0), .some(5), .some(99)]) + func missingAndUnsupportedSeveritiesNeverBecomeErrors(_ rawSeverity: Int?) { + let diagnostic = LanguageServerDiagnostic( + range: LanguageServerRange( + start: LanguageServerPosition(line: 0, utf16Column: 0), + end: LanguageServerPosition(line: 0, utf16Column: 1) + ), + severity: rawSeverity, + message: "Unknown severity", + source: "test", + code: nil + ) + + let editorDiagnostic = EditorDiagnostic( + languageServerDiagnostic: diagnostic, + fileURL: URL(fileURLWithPath: "/tmp/project/main.py") + ) + + #expect(editorDiagnostic.severity == .unknown) + #expect(editorDiagnostic.severity != .error) + } +} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index a5bb002c..429b4973 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -42,7 +42,10 @@ pub fn client_initialize(request: ClientInitializeRequest) -> Result Value { fn lsp_diagnostic_json(diagnostic: &LspClientDiagnostic) -> Value { json!({ - "range": { - "start": { - "line": diagnostic.range.start.line, - "character": diagnostic.range.start.utf16_column - }, - "end": { - "line": diagnostic.range.end.line, - "character": diagnostic.range.end.utf16_column - } - }, + "range": lsp_range_response_json(diagnostic.range), "severity": diagnostic.severity, "message": diagnostic.message, "source": diagnostic.source, - "code": diagnostic.code + "code": diagnostic.code, + "tags": diagnostic.tags, + "relatedInformation": diagnostic.related_information.iter().map(|information| { + json!({ + "location": { + "uri": information.location.uri, + "range": lsp_range_response_json(information.location.range) + }, + "message": information.message + }) + }).collect::>() + }) +} + +fn lsp_range_response_json(range: LspRangeResponse) -> Value { + json!({ + "start": { + "line": range.start.line, + "character": range.start.utf16_column + }, + "end": { + "line": range.end.line, + "character": range.end.utf16_column + } }) } @@ -553,6 +570,37 @@ fn parse_diagnostics(value: Option<&Value>) -> Vec { Value::Number(value) => Some(value.to_string()), _ => None, }), + tags: item + .get("tags") + .and_then(Value::as_array) + .map(|tags| tags.iter().filter_map(Value::as_i64).collect()) + .unwrap_or_default(), + related_information: parse_diagnostic_related_information( + item.get("relatedInformation"), + ), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_diagnostic_related_information( + value: Option<&Value>, +) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let location = item.get("location")?; + Some(LspClientDiagnosticRelatedInformation { + location: LspClientDiagnosticLocation { + uri: location.get("uri")?.as_str()?.to_string(), + range: parse_lsp_range(location.get("range")?)?, + }, + message: item.get("message")?.as_str()?.to_string(), }) }) .collect() diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index b935cad1..d887371b 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -91,6 +91,24 @@ pub struct LspClientDiagnostic { pub message: String, pub source: Option, pub code: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub related_information: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnosticRelatedInformation { + pub location: LspClientDiagnosticLocation, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnosticLocation { + pub uri: String, + pub range: LspRangeResponse, } #[derive(Debug, Clone, Deserialize)] diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index fc43ff03..59af92c8 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -434,6 +434,14 @@ fn client_core_initializes_and_applies_server_capabilities() { assert!(client_capabilities["textDocument"]["synchronization"] .get("didSave") .is_none()); + assert_eq!( + client_capabilities["textDocument"]["publishDiagnostics"]["relatedInformation"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["publishDiagnostics"]["tagSupport"]["valueSet"], + json!([1, 2]) + ); assert_eq!(client_capabilities["window"]["workDoneProgress"], true); let applied = client_apply_server_message(ClientApplyServerMessageRequest { @@ -888,6 +896,23 @@ fn client_core_shapes_feature_responses_for_swift_models() { message: "rename suggestion".to_string(), source: Some("rust-analyzer".to_string()), code: None, + tags: vec![1, 99], + related_information: vec![LspClientDiagnosticRelatedInformation { + location: LspClientDiagnosticLocation { + uri: "file:///tmp/project/lib.rs".to_string(), + range: LspRangeResponse { + start: LspPositionResponse { + line: 3, + utf16_column: 4, + }, + end: LspPositionResponse { + line: 3, + utf16_column: 8, + }, + }, + }, + message: "declared here".to_string(), + }], }], completion_item: None, code_action: None, @@ -900,6 +925,20 @@ fn client_core_shapes_feature_responses_for_swift_models() { code_action_request["params"]["context"]["diagnostics"][0]["range"]["start"]["character"], 12 ); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["tags"], + json!([1, 99]) + ); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["relatedInformation"][0] + ["location"]["uri"], + "file:///tmp/project/lib.rs" + ); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["relatedInformation"][0] + ["location"]["range"]["start"]["character"], + 4 + ); let code_actioned = client_apply_server_message(ClientApplyServerMessageRequest { state: code_actions.state, message: r#"{ @@ -1108,10 +1147,20 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { "start": { "line": 2, "character": 4 }, "end": { "line": 2, "character": 9 } }, - "severity": 1, "source": "pyright", "code": "reportGeneralTypeIssues", - "message": "Example diagnostic" + "message": "Example diagnostic", + "tags": [1, 2, 99], + "relatedInformation": [{ + "location": { + "uri": "file:///tmp/project/types.py", + "range": { + "start": { "line": 8, "character": 2 }, + "end": { "line": 8, "character": 7 } + } + }, + "message": "Type declared here" + }] }] } }"# @@ -1125,7 +1174,33 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { .unwrap(); assert_eq!(stored[0].message, "Example diagnostic"); assert_eq!(stored[0].range.start.utf16_column, 4); + assert_eq!(stored[0].severity, None); + assert_eq!(stored[0].tags, vec![1, 2, 99]); + assert_eq!(stored[0].related_information.len(), 1); + assert_eq!( + stored[0].related_information[0].location.uri, + "file:///tmp/project/types.py" + ); + assert_eq!( + stored[0].related_information[0] + .location + .range + .start + .utf16_column, + 2 + ); + assert_eq!( + stored[0].related_information[0].message, + "Type declared here" + ); assert_eq!(diagnostics.events[0].kind, "diagnostics"); + let event_json = serde_json::to_value(&diagnostics.events[0]).unwrap(); + assert_eq!(event_json["diagnostics"][0]["tags"], json!([1, 2, 99])); + assert_eq!( + event_json["diagnostics"][0]["relatedInformation"][0]["location"]["range"]["start"] + ["utf16Column"], + 2 + ); let registered = client_apply_server_message(ClientApplyServerMessageRequest { state: diagnostics.state, From b499a6b0ddf3392ad60f9d77942165b556c3a12d Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:31:59 +0800 Subject: [PATCH 35/38] Own LSP diagnostics by provider lifecycle --- .../LanguageToolingSessionManager.swift | 80 ++++++++++++- .../Lithe/Views/LSPControlCenterView.swift | 18 ++- .../RunConfigurationIntegrationTests.swift | 106 ++++++++++++++++++ 3 files changed, 192 insertions(+), 12 deletions(-) diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index bfc36c4a..8093f88b 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -42,6 +42,7 @@ final class LanguageToolingSessionManager: ObservableObject { private var languageServers: [String: any LanguageServerSession] = [:] private var languageServerRoots: [String: URL] = [:] private var languageServerSessionIdentities: [String: ObjectIdentifier] = [:] + private var diagnosticsByProviderID: [String: [URL: [LanguageServerDiagnostic]]] = [:] private var languageFeatureProviders: [any LanguageFeatureProvider] private var languageServerFeatureProviders: [String: LanguageServerFeatureProvider] = [:] private var debugAdapters: [String: any DebugAdapterSession] = [:] @@ -92,11 +93,17 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } languageServerStates = languageServerStates.filter { validProviderIDs.contains($0.key) } languageServerInfos = languageServerInfos.filter { validProviderIDs.contains($0.key) } - diagnostics = diagnostics.filter { catalog.provider(for: $0.key) != nil } + diagnosticsByProviderID = diagnosticsByProviderID.filter { + validProviderIDs.contains($0.key) + } + rebuildDiagnostics() languageServerLogs = languageServerLogs.filter { validProviderIDs.contains($0.providerID) } for providerID in changedProviderIDs { stopLanguageServer(providerID: providerID) stopDebugAdapter(providerID: providerID) + if updatedDescriptors[providerID] == nil { + languageServerStates[providerID] = nil + } if runtimeFactory != nil { runtimesByID[providerID] = nil } @@ -163,6 +170,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerRoots[descriptor.id] == normalizedRoot { session = active } else { + clearDiagnostics(providerID: descriptor.id) languageServerSessionIdentities[descriptor.id] = nil languageServers[descriptor.id]?.stop() languageServerFeatureProviders[descriptor.id] = nil @@ -202,6 +210,7 @@ final class LanguageToolingSessionManager: ObservableObject { do { try created.start(rootURL: normalizedRoot) } catch { + clearDiagnostics(providerID: descriptor.id) languageServerSessionIdentities[descriptor.id] = nil languageServerStates[descriptor.id] = .failed( exitCode: nil, @@ -237,14 +246,24 @@ final class LanguageToolingSessionManager: ObservableObject { func closeDocument(_ fileURL: URL) { let standardizedURL = fileURL.standardizedFileURL - diagnostics[standardizedURL] = nil + clearDiagnostics(for: standardizedURL) languageServerSession(for: standardizedURL)?.closeDocument(standardizedURL) } func clearDiagnostics() { + diagnosticsByProviderID = [:] diagnostics = [:] } + func diagnostics(for providerID: String) -> [URL: [LanguageServerDiagnostic]] { + diagnosticsByProviderID[providerID] ?? [:] + } + + func clearDiagnostics(providerID: String) { + guard diagnosticsByProviderID.removeValue(forKey: providerID) != nil else { return } + rebuildDiagnostics() + } + func clearLanguageServerLogs() { languageServerLogs = [] } @@ -275,6 +294,7 @@ final class LanguageToolingSessionManager: ObservableObject { detail: nil ) } + clearDiagnostics(providerID: providerID) languageServerSessionIdentities[providerID] = nil languageServers.removeValue(forKey: providerID)?.stop() languageServerRoots[providerID] = nil @@ -294,7 +314,7 @@ final class LanguageToolingSessionManager: ObservableObject { ) } let sessions = Array(languageServers.values) - diagnostics = [:] + clearDiagnostics() languageServerFeatures = [:] languageServerInfos = [:] languageServers.removeAll() @@ -572,7 +592,7 @@ final class LanguageToolingSessionManager: ObservableObject { func stopAll() { let languageServerSessions = Array(languageServers.values) for session in debugAdapters.values { session.stop() } - diagnostics = [:] + clearDiagnostics() languageServerFeatures = [:] languageServerInfos = [:] languageServers.removeAll() @@ -812,8 +832,13 @@ final class LanguageToolingSessionManager: ObservableObject { sessionIdentity: ObjectIdentifier ) { session.onDiagnostics = { [weak self] fileURL, diagnostics in - guard self?.languageServerSessionIdentities[providerID] == sessionIdentity else { return } - self?.diagnostics[fileURL.standardizedFileURL] = diagnostics + guard let self else { return } + guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return } + self.replaceDiagnostics( + diagnostics, + for: fileURL.standardizedFileURL, + providerID: providerID + ) } session.onFeaturesChange = { [weak self] features in guard let self else { return } @@ -866,6 +891,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerStates[providerID] = state switch state { case .stopped, .failed: + clearDiagnostics(providerID: providerID) languageServerSessionIdentities[providerID] = nil languageServers[providerID] = nil languageServerRoots[providerID] = nil @@ -881,6 +907,48 @@ final class LanguageToolingSessionManager: ObservableObject { } } + private func replaceDiagnostics( + _ updatedDiagnostics: [LanguageServerDiagnostic], + for fileURL: URL, + providerID: String + ) { + let standardizedURL = fileURL.standardizedFileURL + var providerDiagnostics = diagnosticsByProviderID[providerID] ?? [:] + if updatedDiagnostics.isEmpty { + providerDiagnostics[standardizedURL] = nil + } else { + providerDiagnostics[standardizedURL] = updatedDiagnostics + } + diagnosticsByProviderID[providerID] = providerDiagnostics.isEmpty + ? nil + : providerDiagnostics + rebuildDiagnostics() + } + + private func clearDiagnostics(for fileURL: URL) { + let standardizedURL = fileURL.standardizedFileURL + var didChange = false + for providerID in diagnosticsByProviderID.keys.sorted() { + guard var providerDiagnostics = diagnosticsByProviderID[providerID], + providerDiagnostics.removeValue(forKey: standardizedURL) != nil else { continue } + diagnosticsByProviderID[providerID] = providerDiagnostics.isEmpty + ? nil + : providerDiagnostics + didChange = true + } + if didChange { rebuildDiagnostics() } + } + + private func rebuildDiagnostics() { + var flattened: [URL: [LanguageServerDiagnostic]] = [:] + for providerID in diagnosticsByProviderID.keys.sorted() { + for (fileURL, values) in diagnosticsByProviderID[providerID] ?? [:] { + flattened[fileURL, default: []].append(contentsOf: values) + } + } + diagnostics = flattened + } + private func configureDebugCallbacks( _ session: any DebugAdapterSession, providerID: String diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index 461e26de..a1c331c1 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -688,10 +688,12 @@ struct LSPControlCenterView: View { } private func matchingDiagnostics(for descriptor: LanguageProviderDescriptor) -> [EditorDiagnostic] { - model.editorDiagnostics - .filter { descriptor.handles(fileURL: $0.key) } - .values - .flatMap { $0 } + model.languageToolingSessions.diagnostics(for: descriptor.id) + .flatMap { fileURL, diagnostics in + diagnostics.map { + EditorDiagnostic(languageServerDiagnostic: $0, fileURL: fileURL) + } + } .sorted { if $0.severity != $1.severity { return $0.severity.sortOrder < $1.severity.sortOrder } if $0.line != $1.line { return $0.line < $1.line } @@ -700,8 +702,12 @@ struct LSPControlCenterView: View { } private var allDiagnostics: [EditorDiagnostic] { - model.editorDiagnostics.values - .flatMap { $0 } + model.languageToolingSessions.diagnostics + .flatMap { fileURL, diagnostics in + diagnostics.map { + EditorDiagnostic(languageServerDiagnostic: $0, fileURL: fileURL) + } + } .sorted { if $0.severity != $1.severity { return $0.severity.sortOrder < $1.severity.sortOrder } if $0.line != $1.line { return $0.line < $1.line } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index dd8958a9..75386445 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1298,6 +1298,101 @@ struct RunConfigurationIntegrationTests { #expect(harness.manager.languageServerStates["swift"] == .ready) } + @Test + func providerStopAndReconfigurationClearOnlyOwnedDiagnostics() async throws { + let root = URL(fileURLWithPath: "/tmp/lithe-owned-diagnostics", isDirectory: true) + let swiftSource = root.appendingPathComponent("App.swift") + let goSource = root.appendingPathComponent("main.go") + let swiftDescriptor = LanguageProviderDescriptor( + id: "swift", + displayName: "Swift", + fileExtensions: ["swift"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "swift" + ) + let goDescriptor = LanguageProviderDescriptor( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "go" + ) + let swiftProcess = RecordingRawProcessSession() + let goProcess = RecordingRawProcessSession() + let swiftSession = StdioLanguageServerSession( + executableURL: URL(fileURLWithPath: "/usr/bin/sourcekit-lsp"), + arguments: [], + environment: [:], + process: swiftProcess, + core: TestLspClientCore(diagnosticURL: swiftSource) + ) + let goSession = StdioLanguageServerSession( + executableURL: URL(fileURLWithPath: "/usr/bin/gopls"), + arguments: [], + environment: [:], + process: goProcess, + core: TestLspClientCore(diagnosticURL: goSource) + ) + let manager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [swiftDescriptor, goDescriptor]), + runtimes: [ + TestLanguageServerRuntime(descriptor: swiftDescriptor, session: swiftSession), + TestLanguageServerRuntime(descriptor: goDescriptor, session: goSession) + ] + ) + + try manager.synchronizeLanguageServer(for: swiftSource, text: "struct App {}\n", rootURL: root) + Self.emitSuccessfulInitialize(on: swiftProcess) + try manager.synchronizeLanguageServer(for: goSource, text: "package main\n", rootURL: root) + Self.emitSuccessfulInitialize(on: goProcess) + await Self.drainMainActorTasks() + Self.emitPublishDiagnostics(on: swiftProcess) + Self.emitPublishDiagnostics(on: goProcess) + await Self.drainMainActorTasks() + + #expect(manager.diagnostics(for: "swift")[swiftSource]?.count == 1) + #expect(manager.diagnostics(for: "go")[goSource]?.count == 1) + #expect(manager.diagnostics.count == 2) + + manager.stopLanguageServer(providerID: "swift") + #expect(manager.diagnostics(for: "swift").isEmpty) + #expect(manager.diagnostics(for: "go")[goSource]?.count == 1) + #expect(manager.diagnostics.count == 1) + + goProcess.terminate(exitCode: 7) + await Self.drainMainActorTasks() + #expect(manager.diagnostics(for: "go").isEmpty) + #expect(manager.diagnostics.isEmpty) + + try manager.synchronizeLanguageServer(for: goSource, text: "package main\n", rootURL: root) + Self.emitSuccessfulInitialize(on: goProcess) + await Self.drainMainActorTasks() + Self.emitPublishDiagnostics(on: goProcess) + await Self.drainMainActorTasks() + #expect(manager.diagnostics(for: "go")[goSource]?.count == 1) + + let reconfiguredGo = LanguageProviderDescriptor( + id: goDescriptor.id, + displayName: "Go (workspace override)", + fileExtensions: goDescriptor.fileExtensions, + fileNames: goDescriptor.fileNames, + fileNamePrefixes: goDescriptor.fileNamePrefixes, + capabilities: goDescriptor.capabilities, + activationPolicy: goDescriptor.activationPolicy, + languageIdentifier: goDescriptor.languageIdentifier, + languageIdentifiersByExtension: goDescriptor.languageIdentifiersByExtension, + languageIdentifiersByFileName: goDescriptor.languageIdentifiersByFileName, + languageServerLaunch: goDescriptor.languageServerLaunch, + languageServerInstallation: goDescriptor.languageServerInstallation + ) + manager.updateCatalog(LanguageProviderCatalog(descriptors: [swiftDescriptor, reconfiguredGo])) + + #expect(manager.diagnostics(for: "go").isEmpty) + #expect(manager.diagnostics.isEmpty) + } + @Test func languageServerRuntimeStartsFromRustCatalogLaunchMetadata() async throws { let descriptor = LanguageProviderDescriptor( @@ -3123,6 +3218,17 @@ struct RunConfigurationIntegrationTests { ]) } + private static func emitPublishDiagnostics(on process: RecordingRawProcessSession) { + process.emitJSON([ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": [ + "uri": "file:///ignored-by-test-core", + "diagnostics": [] + ] + ]) + } + private static func framedMessages(_ frames: [Data]) -> [String] { frames.compactMap { data in guard let separator = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } From 60bf18e03653e94408e6c7650b17975f9780dc84 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:33:47 +0800 Subject: [PATCH 36/38] Derive LSP control center from runtime truth --- .../LSPControlCenterPresentation.swift | 72 +++++ .../Lithe/Models/AppModel+Development.swift | 2 + Sources/Lithe/Models/AppModel.swift | 62 ++-- .../Lithe/Views/LSPControlCenterView.swift | 275 ++++++++++++------ .../LSPControlCenterPresentationTests.swift | 94 ++++++ 5 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 Sources/Lithe/Application/LSPControlCenterPresentation.swift create mode 100644 Tests/LitheTests/LSPControlCenterPresentationTests.swift diff --git a/Sources/Lithe/Application/LSPControlCenterPresentation.swift b/Sources/Lithe/Application/LSPControlCenterPresentation.swift new file mode 100644 index 00000000..96d72f8c --- /dev/null +++ b/Sources/Lithe/Application/LSPControlCenterPresentation.swift @@ -0,0 +1,72 @@ +import Foundation + +enum LSPServerStatus: Equatable, Sendable { + case starting + case initializing + case active + case stopping + case stopped + case disabled + case error +} + +enum LSPCapabilityPresentationState: Equatable, Sendable { + case unknown + case unsupported + case available + case active +} + +enum LSPControlCenterPresenter { + static func serverStatus( + isDisabled: Bool, + sessionState: LanguageServerSessionState? + ) -> LSPServerStatus { + if isDisabled { + return .disabled + } + + switch sessionState { + case .startingProcess: + return .starting + case .initializing: + return .initializing + case .ready: + return .active + case .stopping: + return .stopping + case .stopped, nil: + return .stopped + case .failed: + return .error + } + } + + static func negotiatedCapabilityState( + _ feature: LanguageServerFeatureSet, + sessionState: LanguageServerSessionState?, + features: LanguageServerFeatureSet? + ) -> LSPCapabilityPresentationState { + guard sessionState == .ready else { + return .unknown + } + return features?.contains(feature) == true ? .available : .unsupported + } + + static func reportedServerVersion(_ serverInfo: LanguageServerInfo?) -> String? { + guard let version = serverInfo?.version? + .trimmingCharacters(in: .whitespacesAndNewlines), + !version.isEmpty else { return nil } + return version + } + + static func integrationState( + isAvailable: Bool, + isActive: Bool = false + ) -> LSPCapabilityPresentationState { + guard isAvailable else { + return .unsupported + } + return isActive ? .active : .available + } +} diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index db66fc99..9a28b855 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -803,6 +803,7 @@ extension AppModel { self.isLoadingLanguageNavigation = false switch result { case .failure(let error): + self.languageNavigationProviderID = nil self.showNotification(error.localizedDescription) case .success(let values): if fallbackToImplementationsIfSelf, @@ -828,6 +829,7 @@ extension AppModel { } } catch { isLoadingLanguageNavigation = false + languageNavigationProviderID = nil showNotification(error.localizedDescription) } } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index fd98c551..77d56478 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -507,36 +507,46 @@ final class AppModel: ObservableObject, Identifiable { var languageServerStatusMessage: String { let usesChinese = settings.language == .simplifiedChinese - if isLoadingLanguageNavigation { - return usesChinese ? "正在加载语言导航..." : "Loading language navigation..." - } - if languageNavigationProviderID != nil { - return usesChinese ? "语言服务器已就绪" : "Language server ready" - } - if let document = activeDocument, - let descriptor = languageProviderCatalog.provider(for: document.url), - descriptor.capabilities.contains(.languageServer) { - if disabledLanguageServerProviderIDs.contains(descriptor.id) { - return usesChinese - ? "\(descriptor.displayName) LSP 已在当前工作区禁用" - : "\(descriptor.displayName) LSP is disabled in this workspace" - } - if let state = languageToolingSessions.languageServerStates[descriptor.id], - case .failed = state { - return usesChinese - ? "\(descriptor.displayName) LSP 异常退出" - : "\(descriptor.displayName) LSP exited unexpectedly" - } - if languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) { - return usesChinese - ? "\(descriptor.displayName) 语言服务器已就绪" - : "\(descriptor.displayName) language server ready" - } + guard let document = activeDocument, + let descriptor = languageProviderCatalog.provider(for: document.url), + descriptor.capabilities.contains(.languageServer) else { + return usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" + } + + let status = LSPControlCenterPresenter.serverStatus( + isDisabled: disabledLanguageServerProviderIDs.contains(descriptor.id), + sessionState: languageToolingSessions.languageServerStates[descriptor.id] + ) + switch status { + case .starting: + return usesChinese + ? "正在启动 \(descriptor.displayName) LSP 进程" + : "Starting the \(descriptor.displayName) LSP process" + case .initializing: + return usesChinese + ? "正在初始化 \(descriptor.displayName) LSP" + : "Initializing \(descriptor.displayName) LSP" + case .active: + return usesChinese + ? "\(descriptor.displayName) 语言服务器已就绪" + : "\(descriptor.displayName) language server ready" + case .stopping: + return usesChinese + ? "正在停止 \(descriptor.displayName) LSP" + : "Stopping \(descriptor.displayName) LSP" + case .stopped: return usesChinese ? "\(descriptor.displayName) 已由 catalog 声明,但当前没有运行中的 LSP 会话" : "\(descriptor.displayName) is declared by the catalog, but no LSP session is running" + case .disabled: + return usesChinese + ? "\(descriptor.displayName) LSP 已在当前工作区禁用" + : "\(descriptor.displayName) LSP is disabled in this workspace" + case .error: + return usesChinese + ? "\(descriptor.displayName) LSP 异常退出" + : "\(descriptor.displayName) LSP exited unexpectedly" } - return usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" } func restartLanguageServers() { diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index a1c331c1..cc5240d9 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -23,6 +23,9 @@ struct LSPControlCenterView: View { ScrollView(.vertical) { VStack(spacing: 8) { globalControls + if model.languageProviderCatalogSnapshot.isDegraded { + catalogDegradedBanner + } if isToolSetupExpanded { languageServerSetupPanel } else { @@ -148,6 +151,39 @@ struct LSPControlCenterView: View { .panelChrome() } + private var catalogDegradedBanner: some View { + let snapshot = model.languageProviderCatalogSnapshot + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(LitheTheme.warning) + Text(copy.catalogDegraded) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 0) + Text(copy.catalogOrigin(snapshot.origin)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.warning) + } + ForEach(Array(snapshot.issues.enumerated()), id: \.offset) { _, issue in + Text("\(issue.path): \(issue.message)") + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(3) + .textSelection(.enabled) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(LitheTheme.warning.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(LitheTheme.warning.opacity(0.35), lineWidth: 1) + ) + ) + } + private var serverList: some View { VStack(alignment: .leading, spacing: 7) { sectionTitle(copy.languageServers) @@ -196,7 +232,7 @@ struct LSPControlCenterView: View { Text(copy.title(for: metrics.status)) .font(.system(size: 11, weight: .medium)) .foregroundStyle(statusColor(metrics.status)) - if metrics.status == .active || metrics.status == .error { + if [.starting, .initializing, .active, .error].contains(metrics.status) { Button { model.disableLanguageServerForCurrentWorkspace(providerID: descriptor.id) } label: { @@ -388,69 +424,111 @@ struct LSPControlCenterView: View { } private func capabilityGrid(_ descriptor: LanguageProviderDescriptor) -> some View { - let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] ?? [] - let rows: [LSPCapabilityRow] = [ + let sessionState = model.languageToolingSessions.languageServerStates[descriptor.id] + let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] + let negotiatedRows: [LSPCapabilityRow] = [ + LSPCapabilityRow( + title: copy.definition, + icon: "arrow.turn.down.right", + state: LSPControlCenterPresenter.negotiatedCapabilityState( + .definition, + sessionState: sessionState, + features: features + ) + ), LSPCapabilityRow( - title: copy.languageServerCapability, - icon: "chevron.left.forwardslash.chevron.right", - declared: descriptor.capabilities.contains(.languageServer), - active: !features.isEmpty || model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) + title: copy.completion, + icon: "text.badge.plus", + state: LSPControlCenterPresenter.negotiatedCapabilityState( + .completion, + sessionState: sessionState, + features: features + ) + ), + LSPCapabilityRow( + title: copy.hover, + icon: "text.bubble", + state: LSPControlCenterPresenter.negotiatedCapabilityState( + .hover, + sessionState: sessionState, + features: features + ) ), LSPCapabilityRow( title: copy.formatting, icon: "text.alignleft", - declared: descriptor.capabilities.contains(.formatting), - active: features.contains(.formatting) + state: LSPControlCenterPresenter.negotiatedCapabilityState( + .formatting, + sessionState: sessionState, + features: features + ) + ) + ] + let integrationRows: [LSPCapabilityRow] = [ + LSPCapabilityRow( + title: copy.run, + icon: "play", + state: LSPControlCenterPresenter.integrationState( + isAvailable: descriptor.capabilities.contains(.run) + ) ), LSPCapabilityRow( title: copy.testing, icon: "checkmark.seal", - declared: descriptor.capabilities.contains(.testing), - active: false + state: LSPControlCenterPresenter.integrationState( + isAvailable: descriptor.capabilities.contains(.testing) + ) ), LSPCapabilityRow( title: copy.debug, icon: "ladybug", - declared: descriptor.capabilities.contains(.debugAdapter), - active: model.languageToolingSessions.activeDebugAdapterIDs.contains(descriptor.id) - ), - LSPCapabilityRow( - title: copy.run, - icon: "play", - declared: descriptor.capabilities.contains(.run), - active: false + state: LSPControlCenterPresenter.integrationState( + isAvailable: descriptor.capabilities.contains(.debugAdapter), + isActive: model.languageToolingSessions.activeDebugAdapterIDs.contains(descriptor.id) + ) ) ] return VStack(alignment: .leading, spacing: 7) { - sectionTitle(copy.capabilities) - LazyVGrid(columns: metricColumns, spacing: 6) { - ForEach(rows) { row in - Button { - model.showNotification(copy.capabilityState(row.title, declared: row.declared, active: row.active)) - } label: { - HStack(spacing: 7) { - Image(systemName: row.icon) - .frame(width: 15) - Text(row.title) - .lineLimit(1) - Spacer(minLength: 0) - Image(systemName: row.active ? "bolt.fill" : row.declared ? "checkmark.circle" : "xmark.circle") - .foregroundStyle(row.active ? LitheTheme.success : row.declared ? LitheTheme.accent : LitheTheme.secondaryText) - } - .font(.system(size: 11.5)) - .foregroundStyle(row.declared ? LitheTheme.primaryText : LitheTheme.secondaryText) - .padding(.horizontal, 7) - .frame(height: 30) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(LitheTheme.raised.opacity(0.55)) - ) + sectionTitle(copy.negotiatedCapabilities) + capabilityRows(negotiatedRows) + sectionTitle(copy.litheIntegrations) + .padding(.top, 3) + capabilityRows(integrationRows) + } + } + + private func capabilityRows(_ rows: [LSPCapabilityRow]) -> some View { + LazyVGrid(columns: metricColumns, spacing: 6) { + ForEach(rows) { row in + Button { + model.showNotification(copy.capabilityState(row.title, state: row.state)) + } label: { + HStack(spacing: 7) { + Image(systemName: row.icon) + .frame(width: 15) + Text(row.title) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: capabilityIcon(for: row.state)) + .foregroundStyle(capabilityColor(for: row.state)) } - .buttonStyle(.plain) - .lithePointer() + .font(.system(size: 11.5)) + .foregroundStyle( + row.state == .unknown || row.state == .unsupported + ? LitheTheme.secondaryText + : LitheTheme.primaryText + ) + .padding(.horizontal, 7) + .frame(height: 30) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(LitheTheme.raised.opacity(0.55)) + ) } + .buttonStyle(.plain) + .lithePointer() } } } @@ -618,13 +696,31 @@ struct LSPControlCenterView: View { private func statusColor(_ status: LSPServerStatus) -> Color { switch status { + case .starting, .initializing: LitheTheme.accent case .active: LitheTheme.success + case .stopping, .disabled: LitheTheme.warning case .stopped: LitheTheme.secondaryText - case .disabled: LitheTheme.warning case .error: LitheTheme.error } } + private func capabilityIcon(for state: LSPCapabilityPresentationState) -> String { + switch state { + case .unknown: "questionmark.circle" + case .unsupported: "xmark.circle" + case .available: "checkmark.circle" + case .active: "bolt.fill" + } + } + + private func capabilityColor(for state: LSPCapabilityPresentationState) -> Color { + switch state { + case .unknown, .unsupported: LitheTheme.secondaryText + case .available: LitheTheme.accent + case .active: LitheTheme.success + } + } + private var languageServerDescriptors: [LanguageProviderDescriptor] { model.languageProviderCatalog.descriptors .filter { $0.capabilities.contains(.languageServer) } @@ -751,32 +847,19 @@ struct LSPControlCenterView: View { let files = matchingProjectFiles(for: descriptor) let openFiles = matchingOpenDocuments(for: descriptor) let diagnostics = matchingDiagnostics(for: descriptor) - let features = model.languageToolingSessions.languageServerFeatures[descriptor.id] ?? [] - let status: LSPServerStatus - let hasServerState = model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) - || !features.isEmpty - || !diagnostics.isEmpty - - if model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id) { - status = .disabled - } else if let state = model.languageToolingSessions.languageServerStates[descriptor.id], - case .failed = state { - status = .error - } else if diagnostics.contains(where: { $0.severity == .error }) { - status = .error - } else if model.languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) || !features.isEmpty { - status = .active - } else if hasServerState { - status = .active - } else { - status = .stopped - } + let status = LSPControlCenterPresenter.serverStatus( + isDisabled: model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id), + sessionState: model.languageToolingSessions.languageServerStates[descriptor.id] + ) + let version = LSPControlCenterPresenter.reportedServerVersion( + model.languageToolingSessions.languageServerInfos[descriptor.id] + ) ?? copy.unknown return LSPProviderMetrics( status: status, subtitle: files.isEmpty ? copy.noMatchingFiles : copy.matchingFiles(files.count), workspacePath: model.workspaceURL?.path ?? copy.noWorkspace, - version: copy.title(for: status), + version: version, fileCount: files.count, openFileCount: openFiles.count, diagnosticCount: diagnostics.count @@ -785,18 +868,10 @@ struct LSPControlCenterView: View { } -private enum LSPServerStatus { - case active - case stopped - case disabled - case error -} - private struct LSPCapabilityRow: Identifiable { let title: String let icon: String - let declared: Bool - let active: Bool + let state: LSPCapabilityPresentationState var id: String { title } } @@ -837,17 +912,24 @@ private struct LSPControlCenterCopy { usesChinese ? "暂无 LSP 事件。" : "No LSP events." } var capabilities: String { usesChinese ? "能力" : "Capabilities" } + var negotiatedCapabilities: String { usesChinese ? "LSP 协商能力" : "LSP Negotiated Capabilities" } + var litheIntegrations: String { usesChinese ? "Lithe 集成" : "Lithe Integrations" } var languageServerCapability: String { usesChinese ? "语言服务器" : "Language Server" } var definition: String { usesChinese ? "定义" : "Definition" } var completion: String { usesChinese ? "补全" : "Completion" } + var hover: String { usesChinese ? "悬停信息" : "Hover" } var formatting: String { usesChinese ? "格式化" : "Formatting" } var testing: String { usesChinese ? "测试" : "Testing" } var debug: String { usesChinese ? "调试" : "Debug" } var run: String { usesChinese ? "运行" : "Run" } var noMatchingFiles: String { usesChinese ? "没有匹配文件" : "No matching files" } var noWorkspace: String { usesChinese ? "未打开工作区" : "No workspace" } + var unknown: String { usesChinese ? "未知" : "Unknown" } var notRunning: String { usesChinese ? "未运行" : "Not running" } var providerConfiguration: String { usesChinese ? "Provider 配置" : "Provider Configuration" } + var catalogDegraded: String { + usesChinese ? "语言 Provider Catalog 正在降级运行" : "Language Provider Catalog is degraded" + } var rustOwnedConfiguration: String { usesChinese ? "由 Rust LSP 配置加载" : "Loaded by Rust LSP configuration" } @@ -876,6 +958,17 @@ private struct LSPControlCenterCopy { return "\(count) matching file\(count == 1 ? "" : "s")" } + func catalogOrigin(_ origin: LanguageProviderCatalogOrigin) -> String { + switch origin { + case .builtin: + usesChinese ? "内置 Catalog" : "Built-in catalog" + case .workspaceOverride: + usesChinese ? "工作区覆盖" : "Workspace override" + case .compatibilityFallback: + usesChinese ? "兼容配置 fallback" : "Compatibility fallback" + } + } + func activationPolicy(_ policy: ToolingActivationPolicy) -> String { switch policy { case .always: @@ -895,28 +988,40 @@ private struct LSPControlCenterCopy { usesChinese ? "Provider ID:\(id)" : "Provider ID: \(id)" } - func capabilityState(_ name: String, declared: Bool, active: Bool) -> String { + func capabilityState(_ name: String, state: LSPCapabilityPresentationState) -> String { if usesChinese { - if active { return "\(name) 当前会话已启用。" } - if declared { return "\(name) 只是由 catalog 声明;当前没有运行中的 LSP 会话。" } - return "\(name) 未由 catalog 声明。" + switch state { + case .unknown: return "\(name) 尚未完成 LSP 初始化协商,当前状态未知。" + case .unsupported: return "\(name) 不受当前服务器或 Lithe 集成支持。" + case .available: return "\(name) 可用。" + case .active: return "\(name) 当前已激活。" + } + } + switch state { + case .unknown: return "\(name) is unknown until LSP initialization completes." + case .unsupported: return "\(name) is not supported by the current server or Lithe integration." + case .available: return "\(name) is available." + case .active: return "\(name) is currently active." } - if active { return "\(name) is enabled in the current session." } - if declared { return "\(name) is only declared by the catalog; no LSP session is running." } - return "\(name) is not declared by the catalog." } func title(for status: LSPServerStatus) -> String { if usesChinese { switch status { - case .active: "运行中" + case .starting: "启动进程中" + case .initializing: "初始化中" + case .active: "已就绪" + case .stopping: "停止中" case .stopped: "未运行" case .disabled: "已禁用" case .error: "错误" } } else { switch status { - case .active: "Running" + case .starting: "Starting" + case .initializing: "Initializing" + case .active: "Ready" + case .stopping: "Stopping" case .stopped: "Not running" case .disabled: "Disabled" case .error: "Error" diff --git a/Tests/LitheTests/LSPControlCenterPresentationTests.swift b/Tests/LitheTests/LSPControlCenterPresentationTests.swift new file mode 100644 index 00000000..05936cb8 --- /dev/null +++ b/Tests/LitheTests/LSPControlCenterPresentationTests.swift @@ -0,0 +1,94 @@ +import Testing +@testable import Lithe + +@Suite("LSP Control Center presentation") +struct LSPControlCenterPresentationTests { + @Test + func runtimeStateAloneDeterminesServerStatus() { + // Code diagnostics are deliberately unrelated to this resolver. A ready + // session remains active even when the editor has an error diagnostic. + let unrelatedEditorSeverity = DiagnosticSeverity.error + + #expect(unrelatedEditorSeverity == .error) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: .startingProcess + ) == .starting) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: .initializing + ) == .initializing) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: .ready + ) == .active) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: .stopping + ) == .stopping) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: .stopped + ) == .stopped) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: nil + ) == .stopped) + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: false, + sessionState: .failed(exitCode: 1, message: "crashed") + ) == .error) + } + + @Test + func disabledWorkspaceSettingTakesPrecedenceOverRuntimeState() { + #expect(LSPControlCenterPresenter.serverStatus( + isDisabled: true, + sessionState: .ready + ) == .disabled) + } + + @Test + func negotiatedCapabilitiesAreUnknownUntilInitializationCompletes() { + #expect(LSPControlCenterPresenter.negotiatedCapabilityState( + .completion, + sessionState: .initializing, + features: .completion + ) == .unknown) + #expect(LSPControlCenterPresenter.negotiatedCapabilityState( + .completion, + sessionState: .ready, + features: [] + ) == .unsupported) + #expect(LSPControlCenterPresenter.negotiatedCapabilityState( + .completion, + sessionState: .ready, + features: .completion + ) == .available) + } + + @Test + func versionComesOnlyFromInitializeServerInfo() { + #expect(LSPControlCenterPresenter.reportedServerVersion(nil) == nil) + #expect(LSPControlCenterPresenter.reportedServerVersion( + LanguageServerInfo(name: "gopls", version: nil) + ) == nil) + #expect(LSPControlCenterPresenter.reportedServerVersion( + LanguageServerInfo(name: "gopls", version: " 0.20.0 ") + ) == "0.20.0") + } + + @Test + func integrationAvailabilityIsNotReportedAsAFalseServerCapability() { + #expect(LSPControlCenterPresenter.integrationState( + isAvailable: false + ) == .unsupported) + #expect(LSPControlCenterPresenter.integrationState( + isAvailable: true + ) == .available) + #expect(LSPControlCenterPresenter.integrationState( + isAvailable: true, + isActive: true + ) == .active) + } +} From 7fae1da54b3aae59cfaf004292f877c45656c147 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 11 Aug 2026 20:36:03 +0800 Subject: [PATCH 37/38] Stabilize LSP timeout regressions under load --- .../RunConfigurationIntegrationTests.swift | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 75386445..a4ce788a 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1173,9 +1173,14 @@ struct RunConfigurationIntegrationTests { #expect(harness.manager.languageServerStates["swift"] == .initializing) #expect(harness.manager.activeLanguageServerIDs.isEmpty) - try await Task.sleep(nanoseconds: 20_000_000) - await Self.drainMainActorTasks() + let didFail = await Self.waitForMainActorCondition { + if case .failed = harness.manager.languageServerStates["swift"] { + return true + } + return false + } + #expect(didFail) #expect(harness.manager.activeLanguageServerIDs.isEmpty) #expect(!harness.process.isRunning) guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else { @@ -1239,9 +1244,11 @@ struct RunConfigurationIntegrationTests { rootURL: harness.root ) { completionResult = $0 } - try await Task.sleep(nanoseconds: 20_000_000) - await Self.drainMainActorTasks() + let didComplete = await Self.waitForMainActorCondition { + completionResult != nil + } + #expect(didComplete) guard case .failure(let error)? = completionResult else { Issue.record("Expected completion timeout to return failure") return @@ -3248,6 +3255,18 @@ struct RunConfigurationIntegrationTests { await Task.yield() await Task.yield() } + + private static func waitForMainActorCondition( + attempts: Int = 100, + _ condition: () -> Bool + ) async -> Bool { + for _ in 0.. Date: Tue, 11 Aug 2026 21:55:37 +0800 Subject: [PATCH 38/38] fix:clean the arch of the java LSP --- Sources/Lithe/Application/AppServices.swift | 3 - .../Lithe/Application/JavaFeatureModel.swift | 21 +- .../Lithe/Models/AppModel+Development.swift | 4 - .../Lithe/Models/AppModel+FeatureState.swift | 2 - Sources/Lithe/Models/AppModel.swift | 18 +- .../Lithe/Models/JavaDiagnosticModels.swift | 34 +- .../Lithe/Models/ProjectSessionManager.swift | 2 +- .../Platform/MacOS/MacServiceContainer.swift | 2 - .../JavaImplementationMarkerService.swift | 17 - .../LanguageToolingSessionManager.swift | 19 +- Sources/Lithe/Views/CodeEditorView.swift | 46 +- Sources/Lithe/Views/JavaProblemsView.swift | 3 - .../RunConfigurationIntegrationTests.swift | 62 +- docs/architecture/language-tooling.md | 45 +- docs/architecture/lsp-runtime-migration.md | 167 ++ docs/architecture/mac-service-boundaries.md | 29 +- docs/architecture/repository-layout.md | 6 +- rust/lithe-core/src/lsp/interface/client.rs | 244 +- rust/lithe-core/src/lsp/interface/engine.rs | 1997 +++++++++++++++++ rust/lithe-core/src/lsp/interface/host.rs | 62 + rust/lithe-core/src/lsp/interface/mod.rs | 2 + .../lithe-core/src/lsp/interface/transport.rs | 79 +- rust/lithe-core/src/lsp/interface/types.rs | 37 + rust/lithe-core/src/lsp/languages/jdt.rs | 524 +++++ rust/lithe-core/src/lsp/languages/mod.rs | 1 + rust/lithe-core/src/lsp/tests.rs | 520 ++++- rust/lithe-core/src/protocol/command.rs | 38 + rust/lithe-core/src/runtime/dispatcher.rs | 257 +++ scripts/verify-service-boundaries.sh | 2 +- shared/contracts/application-boundary.md | 4 +- shared/contracts/rust-core-api.md | 75 +- 31 files changed, 4051 insertions(+), 271 deletions(-) delete mode 100644 Sources/Lithe/Services/JavaImplementationMarkerService.swift create mode 100644 docs/architecture/lsp-runtime-migration.md create mode 100644 rust/lithe-core/src/lsp/interface/engine.rs create mode 100644 rust/lithe-core/src/lsp/languages/jdt.rs diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/AppServices.swift index 6a687d6d..33853b09 100644 --- a/Sources/Lithe/Application/AppServices.swift +++ b/Sources/Lithe/Application/AppServices.swift @@ -36,7 +36,6 @@ final class AppServices { let fileStorage: any FileStorage let fileOperations: any WorkspaceFileOperations let projectRuntimeService: ProjectRuntimeService - let javaImplementationMarkerService: JavaImplementationMarkerService let mavenService: MavenService let runService: RunService let javaDebugService: JavaDebugService @@ -72,7 +71,6 @@ final class AppServices { fileStorage: any FileStorage, fileOperations: any WorkspaceFileOperations, projectRuntimeService: ProjectRuntimeService, - javaImplementationMarkerService: JavaImplementationMarkerService, mavenService: MavenService, runService: RunService, javaDebugService: JavaDebugService, @@ -117,7 +115,6 @@ final class AppServices { self.fileStorage = fileStorage self.fileOperations = fileOperations self.projectRuntimeService = projectRuntimeService - self.javaImplementationMarkerService = javaImplementationMarkerService self.mavenService = mavenService self.runService = runService self.javaDebugService = javaDebugService diff --git a/Sources/Lithe/Application/JavaFeatureModel.swift b/Sources/Lithe/Application/JavaFeatureModel.swift index 47d555a7..0bab1558 100644 --- a/Sources/Lithe/Application/JavaFeatureModel.swift +++ b/Sources/Lithe/Application/JavaFeatureModel.swift @@ -6,11 +6,9 @@ import Foundation /// to the Rust host. @MainActor final class JavaFeatureModel: ObservableObject { - @Published private(set) var javaDiagnostics: [URL: [JavaDiagnostic]] = [:] @Published private(set) var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] = [:] @Published private(set) var javaInlayHints: [URL: [JavaInlayHint]] = [:] - private let markerService: JavaImplementationMarkerService private let operations: any JavaMavenOperations private let workspaceOperations: any WorkspaceOperations private var documentProvider: (@MainActor () -> EditorDocument?)? @@ -22,11 +20,9 @@ final class JavaFeatureModel: ObservableObject { private var debugFeature: JavaDebugFeatureModel? init( - markerService: JavaImplementationMarkerService, operations: any JavaMavenOperations, workspaceOperations: any WorkspaceOperations ) { - self.markerService = markerService self.operations = operations self.workspaceOperations = workspaceOperations } @@ -64,7 +60,6 @@ final class JavaFeatureModel: ObservableObject { func stop() { inlayHintTasks.values.forEach { $0.cancel() } inlayHintTasks.removeAll() - javaDiagnostics = [:] javaCodeVisionHints = [:] javaInlayHints = [:] } @@ -150,10 +145,8 @@ final class JavaFeatureModel: ObservableObject { } func close(_ document: EditorDocument) { - javaDiagnostics[document.url.standardizedFileURL] = nil javaCodeVisionHints[document.url.standardizedFileURL] = nil javaInlayHints[document.url.standardizedFileURL] = nil - markerService.invalidate(document) } func refreshCodeVision( @@ -171,10 +164,9 @@ final class JavaFeatureModel: ObservableObject { blameLines: blame ) let candidates = structure(source: document.text)?.implementationMarkers ?? [] - let markers = await implementationMarkers(for: document, candidates: candidates) - let implementationCounts = Dictionary( - uniqueKeysWithValues: markers.map { ($0.line, $0.implementationCount) } - ) + let implementationCounts = candidates.reduce(into: [Int: Int]()) { counts, marker in + counts[marker.line] = max(counts[marker.line] ?? 0, marker.implementationCount) + } guard documentProvider?()?.id == document.id else { return } javaCodeVisionHints[normalizedURL] = baseHints.map { hint in JavaCodeVisionHint( @@ -253,13 +245,6 @@ final class JavaFeatureModel: ObservableObject { return String(path.dropFirst(rootPath.count + 1)) } - func implementationMarkers( - for document: EditorDocument, - candidates: [JavaImplementationMarker] - ) async -> [JavaImplementationMarker] { - await markerService.markers(for: document, candidates: candidates) - } - func structure(source: String, declarationSources: [String] = []) -> JavaStructureResult? { operations.structure(source: source, declarationSources: declarationSources) } diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index 9a28b855..ac6b2bcc 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -101,10 +101,6 @@ extension AppModel { ) } - func openJavaDiagnostic(_ diagnostic: JavaDiagnostic) { - openDiagnostic(diagnostic) - } - func selectRunConfiguration(_ configuration: RunConfiguration) { runFeature.select(configuration) } diff --git a/Sources/Lithe/Models/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel+FeatureState.swift index 3101275e..c66cd23c 100644 --- a/Sources/Lithe/Models/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel+FeatureState.swift @@ -136,8 +136,6 @@ extension AppModel { var isLoadingNavigation: Bool { isLoadingLanguageNavigation } - var javaDiagnostics: [URL: [JavaDiagnostic]] { javaFeature.javaDiagnostics } - var isLoadingWorkspace: Bool { workspaceFeature.isLoadingWorkspace } var isRefreshingWorkspace: Bool { workspaceFeature.isRefreshingWorkspace } var searchResults: [FileSearchResult] { searchFeature.searchResults } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 77d56478..8b4d8796 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -122,10 +122,7 @@ final class AppModel: ObservableObject, Identifiable { languageToolingSessions.diagnostics } var editorDiagnostics: [URL: [EditorDiagnostic]] { - EditorDiagnostic.merging( - javaDiagnostics, - languageServerDiagnostics: languageDiagnostics - ) + EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) } private var workspaceFeatureObservation: AnyCancellable? private var runtimeFeatureObservation: AnyCancellable? @@ -234,7 +231,6 @@ final class AppModel: ObservableObject, Identifiable { fileOperations: services.fileOperations ) javaFeature = JavaFeatureModel( - markerService: services.javaImplementationMarkerService, operations: services.javaMavenOperations, workspaceOperations: services.workspaceOperations ) @@ -565,13 +561,6 @@ final class AppModel: ObservableObject, Identifiable { showNotification(settings.language == .simplifiedChinese ? "语言服务器诊断已清空" : "Language server diagnostics cleared") } - func implementationMarkers( - for document: EditorDocument, - candidates: [JavaImplementationMarker] - ) async -> [JavaImplementationMarker] { - await javaFeature.implementationMarkers(for: document, candidates: candidates) - } - func javaStructure(source: String, declarationSources: [String] = []) -> JavaStructureResult? { javaFeature.structure(source: source, declarationSources: declarationSources) } @@ -651,6 +640,11 @@ final class AppModel: ObservableObject, Identifiable { if let previousWorkspaceURL = workspaceURL { workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL) } + // A workspace root is a hard language-server ownership boundary. Stop + // every provider session before replacing the catalog or clearing the + // document projection so no old-root documents, diagnostics, or + // responses can survive into the next workspace. + languageToolingSessions.stopAll() reloadLanguageProviderCatalog(for: normalizedURL) stopTerminalSessions() languageTestService.reset() diff --git a/Sources/Lithe/Models/JavaDiagnosticModels.swift b/Sources/Lithe/Models/JavaDiagnosticModels.swift index 861592f3..c0619544 100644 --- a/Sources/Lithe/Models/JavaDiagnosticModels.swift +++ b/Sources/Lithe/Models/JavaDiagnosticModels.swift @@ -153,37 +153,15 @@ struct EditorDiagnostic: Identifiable, Hashable, Sendable { ) } - static func merging( - _ editorDiagnostics: [URL: [EditorDiagnostic]], - languageServerDiagnostics: [URL: [LanguageServerDiagnostic]] + static func fromLanguageServerDiagnostics( + _ diagnosticsByFile: [URL: [LanguageServerDiagnostic]] ) -> [URL: [EditorDiagnostic]] { - var merged = Dictionary(uniqueKeysWithValues: editorDiagnostics.map { - ($0.key.standardizedFileURL, $0.value) - }) - for (fileURL, diagnostics) in languageServerDiagnostics { + diagnosticsByFile.reduce(into: [URL: [EditorDiagnostic]]()) { mapped, entry in + let (fileURL, diagnostics) = entry let normalizedURL = fileURL.standardizedFileURL - var existing = merged[normalizedURL] ?? [] - for diagnostic in diagnostics.map({ + mapped[normalizedURL, default: []].append(contentsOf: diagnostics.map { EditorDiagnostic(languageServerDiagnostic: $0, fileURL: normalizedURL) - }) where !existing.contains(where: { $0.semanticIdentity == diagnostic.semanticIdentity }) { - existing.append(diagnostic) - } - merged[normalizedURL] = existing + }) } - return merged - } - - private var semanticIdentity: String { - [ - String(line), String(utf16Column), String(endLine), String(endUTF16Column), - source ?? "", code ?? "", message - ].joined(separator: "\u{1F}") } } - -// Transitional source compatibility while the Java feature moves onto the -// editor-wide diagnostics capability. -typealias JavaDiagnosticSeverity = DiagnosticSeverity -typealias JavaDiagnosticTag = DiagnosticTag -typealias JavaDiagnosticRelatedInformation = DiagnosticRelatedInformation -typealias JavaDiagnostic = EditorDiagnostic diff --git a/Sources/Lithe/Models/ProjectSessionManager.swift b/Sources/Lithe/Models/ProjectSessionManager.swift index 8bc02678..8282f3b4 100644 --- a/Sources/Lithe/Models/ProjectSessionManager.swift +++ b/Sources/Lithe/Models/ProjectSessionManager.swift @@ -149,7 +149,7 @@ final class ProjectSessionManager: ObservableObject { func stopAllSessions() { for model in sessions { - model.stopTerminalSessions() + model.shutdownProjectSession() } } diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index aa027802..4d7a25bb 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -149,7 +149,6 @@ final class MacServiceContainer { javaMavenOperations: javaMavenOperations, runConfigurationOperations: runConfigurationStore ) - let javaImplementationMarkerService = JavaImplementationMarkerService() let gitOperations = RustGitOperations(core: rustCore) let workspaceOperations = RustWorkspaceOperations(core: rustCore) let localHistoryOperations = RustLocalHistoryOperations(core: rustCore) @@ -189,7 +188,6 @@ final class MacServiceContainer { fileStorage: fileStorage, fileOperations: fileOperations, projectRuntimeService: runtimeService, - javaImplementationMarkerService: javaImplementationMarkerService, mavenService: mavenService, runService: runService, javaDebugService: javaDebugService, diff --git a/Sources/Lithe/Services/JavaImplementationMarkerService.swift b/Sources/Lithe/Services/JavaImplementationMarkerService.swift deleted file mode 100644 index 2d34d93b..00000000 --- a/Sources/Lithe/Services/JavaImplementationMarkerService.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -/// Compatibility boundary for Java implementation markers. The UI call sites -/// remain in place, but marker resolution belongs to the Rust LSP host. -@MainActor -final class JavaImplementationMarkerService: @unchecked Sendable { - init() {} - - func invalidate(_: EditorDocument) {} - - func markers( - for _: EditorDocument, - candidates _: [JavaImplementationMarker] - ) async -> [JavaImplementationMarker] { - [] - } -} diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 8093f88b..8426c0b3 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -170,10 +170,10 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerRoots[descriptor.id] == normalizedRoot { session = active } else { - clearDiagnostics(providerID: descriptor.id) - languageServerSessionIdentities[descriptor.id] = nil - languageServers[descriptor.id]?.stop() - languageServerFeatureProviders[descriptor.id] = nil + // Retire every projection of the previous root before resolving a + // replacement. If executable resolution or construction fails, + // the UI must not retain a stale ready session from the old root. + stopLanguageServer(providerID: descriptor.id) recordLanguageServerLog( providerID: descriptor.id, level: .info, @@ -181,15 +181,18 @@ final class LanguageToolingSessionManager: ObservableObject { detail: descriptor.languageServerLaunch?.executableNames.joined(separator: ", ") ) guard let created = runtime.makeLanguageServerSession() else { + let message = runtime.unavailableToolingMessage ?? descriptor.displayName + languageServerStates[descriptor.id] = .failed( + exitCode: nil, + message: message + ) recordLanguageServerLog( providerID: descriptor.id, level: .error, message: "Language server executable was not found", - detail: runtime.unavailableToolingMessage ?? descriptor.displayName - ) - throw LanguageToolingSessionError.toolingUnavailable( - runtime.unavailableToolingMessage ?? descriptor.displayName + detail: message ) + throw LanguageToolingSessionError.toolingUnavailable(message) } let featureProvider = LanguageServerFeatureProvider( providerID: descriptor.id, diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 174ff41c..66ff3e2d 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -219,7 +219,6 @@ struct CodeEditorView: NSViewRepresentable { var appliedNavigationTargetID: UUID? var foldRegions: [JavaFoldRegion] = [] var collapsedFoldIDs: Set = [] - private var implementationValidationTask: Task? private var markdownImagePasteMonitor: Any? private weak var markdownScrollView: NSScrollView? private var markdownScrollObserver: NSObjectProtocol? @@ -469,8 +468,17 @@ struct CodeEditorView: NSViewRepresentable { collapsedIDs: collapsedFoldIDs, onToggle: { [weak self] region in self?.toggleFold(region) } ) - implementationValidationTask?.cancel() - gutter?.updateImplementationMarkers([]) { [weak model, weak document] marker in + // `java.structure` is an explicit local editor fallback. It does + // not validate markers or own any language-server lifecycle. + let markers: [JavaImplementationMarker] + if let document, + fileExtension.lowercased() == "java", + let model { + markers = model.javaStructure(source: document.text)?.implementationMarkers ?? [] + } else { + markers = [] + } + gutter?.updateImplementationMarkers(markers) { [weak model, weak document] marker in guard let document else { return } model?.findJavaImplementations( line: marker.line, @@ -478,30 +486,6 @@ struct CodeEditorView: NSViewRepresentable { in: document.url ) } - guard let document, - fileExtension.lowercased() == "java", - let model else { return } - let candidates = model.javaStructure(source: document.text)?.implementationMarkers ?? [] - guard !candidates.isEmpty else { return } - implementationValidationTask = Task { @MainActor [weak self, weak document, weak model] in - guard let self, - let document, - let model else { return } - let markers = await model.implementationMarkers( - for: document, - candidates: candidates - ) - guard !Task.isCancelled, - self.document?.id == document.id else { return } - self.gutter?.updateImplementationMarkers(markers) { [weak model, weak document] marker in - guard let document else { return } - model?.findJavaImplementations( - line: marker.line, - utf16Column: marker.utf16Column, - in: document.url - ) - } - } } func updateCodeVisionAndBlame() { @@ -695,7 +679,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate { private var foldRegions: [JavaFoldRegion] = [] private var collapsedFoldIDs: Set = [] private var onToggleFold: ((JavaFoldRegion) -> Void)? - private var diagnostics: [JavaDiagnostic] = [] + private var diagnostics: [EditorDiagnostic] = [] private var fadedCodeRanges: [NSRange] = [] private var linkRange: NSRange? private var trackingArea: NSTrackingArea? @@ -746,7 +730,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate { lineIndex.lineRange(forLine: line) } - func updateDiagnostics(_ diagnostics: [JavaDiagnostic]) { + func updateDiagnostics(_ diagnostics: [EditorDiagnostic]) { self.diagnostics = diagnostics updateEditorDecorations() } @@ -1467,7 +1451,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate { return true } - private func diagnosticRange(for diagnostic: JavaDiagnostic, in source: NSString) -> NSRange? { + private func diagnosticRange(for diagnostic: EditorDiagnostic, in source: NSString) -> NSRange? { guard source.length > 0 else { return nil } let lastLine = max(0, lineIndex.lineCount - 1) let startLine = min(max(0, diagnostic.line), lastLine) @@ -1489,7 +1473,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate { lineIndex.lineCount } - private func diagnosticColor(for severity: JavaDiagnosticSeverity) -> NSColor { + private func diagnosticColor(for severity: DiagnosticSeverity) -> NSColor { switch severity { case .unknown: NSColor.systemGray case .error: NSColor.systemRed diff --git a/Sources/Lithe/Views/JavaProblemsView.swift b/Sources/Lithe/Views/JavaProblemsView.swift index a6c4b056..6a6652c2 100644 --- a/Sources/Lithe/Views/JavaProblemsView.swift +++ b/Sources/Lithe/Views/JavaProblemsView.swift @@ -188,6 +188,3 @@ struct ProblemsView: View { } } } - -/// Compatibility alias for older call sites and persisted UI integrations. -typealias JavaProblemsView = ProblemsView diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index a4ce788a..af62ff5f 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -792,19 +792,17 @@ struct RunConfigurationIntegrationTests { } @Test - func editorDiagnosticsMergeLanguagesAndMapLSPSeverities() throws { + func editorDiagnosticsMapLanguageServerFilesAndSeverities() throws { let javaURL = URL(fileURLWithPath: "/tmp/mixed/src/Main.java") let pythonURL = URL(fileURLWithPath: "/tmp/mixed/api/main.py") - let javaDiagnostic = EditorDiagnostic( - id: "java-warning", - fileURL: javaURL, - line: 2, - utf16Column: 4, - endLine: 2, - endUTF16Column: 8, - severity: .warning, + let javaDiagnostic = LanguageServerDiagnostic( + range: LanguageServerRange( + start: LanguageServerPosition(line: 2, utf16Column: 4), + end: LanguageServerPosition(line: 2, utf16Column: 8) + ), + severity: 2, message: "Java warning", - source: "java", + source: "jdtls", code: "java-warning", tags: [], relatedInformation: [] @@ -823,19 +821,21 @@ struct RunConfigurationIntegrationTests { ) } - let merged = EditorDiagnostic.merging( - [javaURL: [javaDiagnostic]], - languageServerDiagnostics: [pythonURL: languageDiagnostics] + let mapped = EditorDiagnostic.fromLanguageServerDiagnostics( + [javaURL: [javaDiagnostic], pythonURL: languageDiagnostics] ) - #expect(merged[javaURL.standardizedFileURL] == [javaDiagnostic]) - let python = try #require(merged[pythonURL.standardizedFileURL]) + let java = try #require(mapped[javaURL.standardizedFileURL]) + #expect(java.count == 1) + #expect(java[0].severity == .warning) + #expect(java[0].source == "jdtls") + let python = try #require(mapped[pythonURL.standardizedFileURL]) #expect(python.map(\.severity) == [.error, .warning, .information, .hint]) #expect(python.allSatisfy { $0.source == "pyright" }) } @Test - func editorDiagnosticsDeduplicateTheSameProviderResult() throws { + func editorDiagnosticsPreserveTheAuthoritativeLanguageServerResult() throws { let fileURL = URL(fileURLWithPath: "/tmp/mixed/src/Main.java") let lsp = LanguageServerDiagnostic( range: LanguageServerRange( @@ -847,14 +847,14 @@ struct RunConfigurationIntegrationTests { source: "java", code: "resolve" ) - let existing = EditorDiagnostic(languageServerDiagnostic: lsp, fileURL: fileURL) - let merged = EditorDiagnostic.merging( - [fileURL: [existing]], - languageServerDiagnostics: [fileURL: [lsp]] + let mapped = EditorDiagnostic.fromLanguageServerDiagnostics( + [fileURL: [lsp]] ) - #expect(merged[fileURL.standardizedFileURL] == [existing]) + let diagnostics = try #require(mapped[fileURL.standardizedFileURL]) + #expect(diagnostics.count == 1) + #expect(diagnostics[0] == EditorDiagnostic(languageServerDiagnostic: lsp, fileURL: fileURL)) } @Test @@ -2525,26 +2525,6 @@ struct RunConfigurationIntegrationTests { #expect(report.recovery.contains("JAVA_HOME")) } - @Test - func javaImplementationMarkersStayBehindTheRustLSPHostBoundary() async { - let service = JavaImplementationMarkerService() - let root = URL(fileURLWithPath: "/tmp/lithe-java-marker-boundary", isDirectory: true) - let document = EditorDocument( - url: root.appendingPathComponent("src/Main.java"), - text: "interface Service {}\nclass Impl implements Service {}\n", - modificationDate: nil - ) - let candidates = [ - JavaImplementationMarker(line: 0, utf16Column: 10, isType: true), - JavaImplementationMarker(line: 1, utf16Column: 6, isType: false) - ] - - service.invalidate(document) - let markers = await service.markers(for: document, candidates: candidates) - - #expect(markers.isEmpty) - } - @Test func mavenDebugUsesSharedDebugLaunchPlan() throws { let root = URL(fileURLWithPath: "/tmp/lithe-debug-service", isDirectory: true) diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 65739d0d..f9d46d35 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -10,7 +10,7 @@ 1. 编辑器只依赖统一的 `LanguageFeatureProvider`,不直接依赖具体语言服务器。 2. 轻量本地能力无需外部进程,LSP 是按需启动的语义增强层。 3. 可调用的 LSP 功能以服务器 `initialize` 响应和动态注册结果为准,不能根据语言名称硬编码。 -4. JSON-RPC 状态机和结果归一化属于 Rust Core;进程、stdio 和可执行文件发现属于平台 adapter。 +4. LSP 进程、stdio、JSON-RPC 状态机、deadline 和结果归一化属于 Rust Core;平台 adapter 只负责可执行文件与运行环境发现。 5. 单个 provider 失败、缺失或返回空结果时,不应阻断仍可工作的本地能力。 ## 组件边界 @@ -21,10 +21,9 @@ flowchart LR MANAGER --> ROUTER["LanguageFeatureProvider routing"] ROUTER --> BUILTIN["Builtin provider
keywords + current-file symbols"] ROUTER --> LSPPROVIDER["LSP provider
server capabilities"] - LSPPROVIDER --> SESSION["StdioLanguageServerSession"] - SESSION --> CORE["Rust LSP client core
state + JSON-RPC + normalization"] - SESSION --> PROCESS["RawProcessSession
stdio transport"] - PROCESS --> SERVER["gopls / jdtls / rust-analyzer / ..."] + LSPPROVIDER --> SESSION["Swift semantic facade
opaque operation IDs"] + SESSION --> CORE["Rust LSP runtime
process + state + deadlines + stdio"] + CORE --> SERVER["gopls / jdtls / rust-analyzer / ..."] ``` | 层 | 职责 | 不负责 | @@ -33,29 +32,31 @@ flowchart LR | `LanguageFeatureProvider` | 声明单项能力、优先级和统一结果类型 | 维护 UI 状态 | | `BuiltinLanguageFeatureProvider` | 当前文件标识符、轻量 hover/导航、语言关键字 | 类型推断、跨文件索引 | | `LanguageServerFeatureProvider` | 将已协商的服务器能力适配到统一 provider 接口 | 猜测服务器能力 | -| `StdioLanguageServerSession` | 串联 Rust 状态机与进程 transport,管理请求回调和生命周期 | 解析每种服务器的私有协议 | -| Rust Core | LSP state、请求 ID、frame、UTF-16 位置、结果归一化、动态能力 | 可执行文件发现、子进程和线程模型 | -| macOS adapter | 工具发现、环境变量、`Process`/`Pipe`、终止进程 | 语言功能路由和协议语义 | +| `StdioLanguageServerSession` | 调用语义命令、投影 typed event,并以不透明 operation ID 交付 UI 回调 | LSP 请求 ID、frame、文档版本、协议超时或子进程 | +| Rust Core | LSP 子进程与 stdio、session/document state、请求 ID、deadline、frame、UTF-16 位置、结果归一化、动态能力 | 可执行文件发现、UI provider 路由 | +| macOS adapter | 工具发现、环境变量和用户可执行文件覆盖 | LSP 子进程、语言功能路由和协议语义 | Rust Core 的 LSP 实现统一收在 `rust/lithe-core/src/lsp/`,根模块只作为稳定 facade,command runtime 仍通过 `crate::lsp::*` 使用公开契约: ```text lsp/ -├── interface/ # 通用 LSP 协议、client state、transport 和 session host +├── interface/ # 通用 LSP engine、协议 reducer、transport 与稳定 DTO │ ├── types.rs │ ├── client.rs │ ├── transport.rs -│ └── host.rs +│ ├── host.rs +│ └── engine.rs ├── lightweight/ # 不启动语言服务器的编辑、snippet 和当前文件符号能力 │ ├── edits.rs │ ├── snippets.rs │ └── symbols.rs └── languages/ # provider catalog 与语言/宿主模型 adapter ├── catalog.rs + ├── jdt.rs └── swift.rs ``` -共享的 LSP position/range、client request/response/event 类型只能定义在 `interface/types.rs`。`lightweight` 可以依赖这些协议 DTO,但 `interface` 不依赖轻量实现。`languages/swift.rs` 目前只负责 Swift 宿主 DTO 与标准 LSP JSON 之间的转换,并不表示 SourceKit-LSP 私有协议;真正的服务器私有扩展仍应通过独立 adapter 接入。provider catalog 位于 `languages`,因为它描述可动态加载的语言/provider 元数据,而不是 client 状态机的一部分。 +共享的 LSP position/range 与协议 DTO 只能定义在 `interface/types.rs`;对应用公开的 runtime command/event DTO 位于 `interface/engine.rs`。`lightweight` 可以依赖这些协议 DTO,但 `interface` 不依赖轻量实现。`languages/jdt.rs` 封装 JDTLS 启动参数、配置与虚拟源码语义,generic engine 不按 Java 硬编码 capability。provider catalog 位于 `languages`,因为它描述可动态加载的语言/provider 元数据,而不是 client 状态机的一部分。 ## Provider 路由 @@ -132,18 +133,18 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid 当前 transport 是 LSP 标准的 stdio `Content-Length` framing。一个 provider 在一个 workspace root 下复用一个 session;同一 provider 切换到另一个 root 时,manager 会停止旧 session 并创建新 session。 -生产路径由 Rust `LspHost` 持有长生命周期 `sessionID -> LspClientState` registry。Swift 只保存不透明 `sessionID`,open/change/request/server-message/shutdown 请求不再携带整份 state 或已打开文档全文。registry 锁只保护 handle 的查找和增删,每个 session 独立串行化状态变更,因此不同 workspace 不会因一次 LSP reducer 调用而互相阻塞。Rust Core 只返回待发送 JSON-RPC 消息、typed event 和精简 capability 摘要。旧的 reducer API 暂时保留给未迁移 adapter 和纯函数测试,不再用于 `RustCoreBridge` 的 stdio 生产会话。 +生产路径由 Rust engine 持有长生命周期 `sessionID -> RuntimeSession` registry。每个 runtime 同时拥有子进程、stdio、frame buffer、文档版本、pending request/deadline、capability 和 diagnostics;Swift 只保存不透明 `sessionID` 与 application-level `operationID`。`syncDocument` 由 Rust 决定发送 version 1 的 `didOpen` 或递增版本的 `didChange`,`pollEvents` 只返回 typed state/feature/diagnostic/result/error 事件。协议 reducer/host 只作为 engine 内部实现与纯函数测试 seam,不属于应用公开命令面。 启动顺序: -1. adapter 启动进程并先安装 stdout/stderr handler,避免丢失启动阶段输出; -2. Rust `LspHost` 创建 session handle、生成 `initialize` 并在内部记录 pending request; -3. 收到响应后,Rust Core 保存服务器 capability 并生成 `initialized`; +1. Swift 完成可执行文件和环境发现,向 Rust 提交 typed `startServer`; +2. Rust engine 创建 session、启动进程并安装 stdout/stderr reader,再发送 `initialize`; +3. 收到响应后,Rust 保存服务器 capability,发送 `initialized` 和 provider adapter 通知; 4. manager 发布实际 capability,随后通过 `didOpen`/全量 `didChange` 同步文档; -5. 功能请求按 request ID 回到对应 completion handler。 +5. Rust 以 LSP request ID 关联 deadline,并用不透明 operation ID 把 terminal result 投影给 Swift。 服务端 capability 可以来自 initialize 响应,也可以通过 `client/registerCapability` 和 -`client/unregisterCapability` 动态变化。当前客户端会处理 diagnostics,并对 workspace configuration、workspace folders 查询和 work-done progress 创建返回保守的空值响应;未知的服务端 request 返回 JSON-RPC `Method not found`,未知 notification 作为事件保留。 +`client/unregisterCapability` 动态变化。客户端处理有文档/version 归属的 diagnostics、workspace configuration/folders、work-done progress 和 apply-edit 协议;未知的服务端 request 返回 JSON-RPC `Method not found`,未知 notification 作为 typed log/event 保留。 关闭文档时发送 `textDocument/didClose` 并清除该文档诊断。停止 session 时先请求 `shutdown`,收到响应后发送 `exit`;若服务器无响应,则由超时路径强制停止进程。不要直接以 `terminate()` 代替正常 LSP 关闭流程。 @@ -151,8 +152,8 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid - 只支持 stdio transport,尚无 socket/TCP 或服务器自定义握手 adapter。 - session 当前以 provider ID 和单个 workspace root 为单位,尚无 multi-root session。 -- `workspace/applyEdit` 和服务器私有 request 没有通用处理层;客户端不会宣称未实现的 `applyEdit` 能力。 -- 编辑器尚未实现 snippet tabstop 会话,因此 initialize 明确声明 `snippetSupport: false`;completion 中的 snippet 只会降级成纯文本。 +- `workspace/applyEdit` 只提供协议确认和 normalized edit 数据,实际应用仍必须经过编辑器工作区安全校验。 +- initialize 可协商 snippet、resolve、inlay hint、folding range、code lens 和 workspace symbol;UI 只启用已完整投影且服务器实际声明的能力。 - 文档同步当前发送全量文本,没有按服务器类型实现增量 diff。 - catalog 描述的是“可尝试启动的工具”;最终功能必须以运行时服务器 capability 为准。 - project config 是受信任的项目配置,只接受 schema 中的 typed 字段,不执行 shell 命令。 @@ -164,13 +165,13 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid 3. 确认服务器支持 stdio 和标准 `Content-Length` framing。 4. 不在 UI 或 manager 中按语言写分支;服务器差异应进入 descriptor 或独立 adapter。 5. 用 initialize 响应验证 capability,不把 catalog 的 `languageServer` 标记当成 feature 支持证明。 -6. 至少测试 initialize、didOpen/change/close、一个功能请求、shutdown/exit 和异常退出。 -7. 包含空结果、服务器 error、UTF-16、带空格/非 ASCII 文件 URI,以及启动即输出的场景。 +6. 至少测试 initialize timeout/error、didOpen/change/close、请求 timeout/late response、shutdown/exit、强制停止和异常退出。 +7. 包含 malformed/partial/multiple frame、动态能力、stale diagnostics、UTF-16、带空格/非 ASCII 文件 URI,以及启动即输出的场景。 ## 真实 gopls 验证 [`RealGoplsIntegrationTests.swift`](../../Tests/LitheTests/RealGoplsIntegrationTests.swift) -会穿过 manager、Swift session、macOS process adapter 和真实 Rust Core。测试默认不启动外部工具,需要显式开启: +会穿过 manager、Swift semantic facade 和拥有进程的真实 Rust Core。测试默认不启动外部工具,需要显式开启: ```bash scripts/build-rust-core.sh --debug --target aarch64-apple-darwin diff --git a/docs/architecture/lsp-runtime-migration.md b/docs/architecture/lsp-runtime-migration.md new file mode 100644 index 00000000..8d363961 --- /dev/null +++ b/docs/architecture/lsp-runtime-migration.md @@ -0,0 +1,167 @@ +# Rust LSP Runtime Migration + +This document records the ownership audit that preceded the `feat/LSP` +runtime migration. It is intentionally implementation-oriented: each phase +names the state owner, the compatibility code that must disappear, and the +evidence required before the phase is complete. + +The Windows application is not part of this iteration. The public Rust +contract remains cross-platform, but adapting the Qt client is tracked by the +Windows workstream. + +## Baseline ownership audit + +At commit `7fae1da`, Rust owned the protocol reducer while Swift still owned +the live runtime: + +```text +Swift LanguageToolingSessionManager + -> Swift StdioLanguageServerSession + - child process and stdio + - Content-Length read buffer + - initialized / stopping state + - opened-document set and pending documents + - request completion handlers and timeout tasks + - shutdown fallback + -> Rust LspHost handle + - JSON-RPC construction and parsing + - request IDs + - document versions + - negotiated capabilities + - diagnostics +``` + +That split left the following duplicated or competing state: + +| Concern | Swift baseline | Rust baseline | +| --- | --- | --- | +| Document lifecycle | `openedDocumentURIs`, `pendingDocuments` | `open_documents` | +| Initialization | `isInitialized` and process callbacks | `initialized` | +| Request lifecycle | `responseHandlers`, timeout tasks | `pending_requests` | +| Transport | `readBuffer`, raw process send/receive | stateless frame/parser functions | +| Diagnostics | provider projection plus legacy Java store | URI-indexed diagnostics | +| Shutdown | timer and force terminate | shutdown JSON-RPC reducer | + +The old public command surface also exposed implementation details: +`lsp.clientOpenDocument`, `lsp.clientChangeDocument`, +`lsp.clientApplyServerMessage`, `lsp.sessionExecute`, `lsp.frameMessage`, and +`lsp.parseServerMessages`. + +## Target ownership + +```text +SwiftUI / application facade + -> semantic Rust commands + startServer / stopServer + syncDocument / closeDocument + completion / hover / navigation / rename / format / code actions + resolve / execute / cancel + pollEvents + -> Rust LSP engine + provider adapter and launch configuration + process + stdin/stdout/stderr + lifecycle state machine + framing + JSON-RPC + document and version store + pending request deadlines and terminal outcomes + negotiated capabilities + diagnostics by session/document/version + graceful shutdown, crash handling, and restart isolation +``` + +Swift may retain UI projections and application-level completion closures +keyed by opaque operation IDs. It must not retain LSP request IDs, raw JSON, +framing buffers, child-process handles, document-open truth, or protocol +timeouts. + +## Migration phases + +### 1. State convergence + +- Add one Rust lifecycle enum covering process start through terminal states. +- Replace split open/change calls with `syncDocument`; Rust decides whether to + emit `didOpen` version 1 or `didChange` with the next version. +- Track request metadata and deadlines in Rust. +- Store diagnostic version metadata and clear it on close, stop, crash, + restart, provider reconfiguration, and workspace replacement. + +Primary files: + +- `rust/lithe-core/src/lsp/interface/{engine,host,client,types}.rs` +- `rust/lithe-core/src/lsp/tests.rs` + +### 2. Runtime and transport + +- Spawn and own the language-server child process in Rust using a + cross-platform process abstraction. +- Move stdin/stdout/stderr, partial-frame buffering, and malformed-frame + failure handling into the Rust session. +- Add an event queue drained through `lsp.pollEvents`. +- Implement initialize, request, and shutdown deadlines; fail every pending + request exactly once on timeout, cancellation, crash, stop, or restart. + +Primary files: + +- `rust/lithe-core/src/lsp/interface/{engine,transport}.rs` +- `rust/lithe-core/src/protocol/command.rs` +- `rust/lithe-core/src/runtime/dispatcher.rs` + +### 3. Application facade + +- Reduce `StdioLanguageServerSession.swift` to semantic commands, event + polling, model conversion, and opaque operation completion delivery. +- Stop constructing `RawProcessSession` for LSP. DAP keeps its independent + transport boundary. +- Remove state-passing and raw-message APIs from `RustCoreBridge.swift`. +- Keep `LanguageToolingSessionManager` as the UI-facing provider router and + read-only projection. + +Primary files: + +- `Sources/Lithe/Services/StdioLanguageServerSession.swift` +- `Sources/Lithe/Services/StdioLanguageProviderRuntime.swift` +- `Sources/Lithe/Services/LanguageToolingSessionManager.swift` +- `Sources/Lithe/Core/RustCoreBridge.swift` +- `Sources/Lithe/Core/Ports/LanguageTooling.swift` + +### 4. Provider convergence and legacy deletion + +- Keep Maven, Run, Debug, JDK discovery, and local lightweight parsing outside + the LSP runtime. +- Put JDTLS launch arguments, Java configuration responses, and virtual source + semantics behind the Rust provider adapter. +- Delete the empty Java diagnostics and implementation-marker compatibility + paths rather than retaining a second potential truth source. +- Delete the legacy client/session/frame/parse command surface after the + production facade has migrated. + +Primary files: + +- `rust/lithe-core/src/lsp/languages/jdt.rs` +- `Sources/Lithe/Application/JavaFeatureModel.swift` +- `Sources/Lithe/Models/JavaDiagnosticModels.swift` +- `Sources/Lithe/Views/CodeEditorView.swift` + +## Completion evidence + +The migration is complete only when tests demonstrate all of the following: + +1. A spawned process that never initializes cannot become ready. +2. An initialize error cannot become ready. +3. Two syncs emit open version 1 and change version 2. +4. A crash fails pending operations with `serverExited`. +5. A request deadline removes the pending request. +6. A late response after timeout is ignored. +7. Responses from an old session cannot affect a restarted session. +8. Old-session and old-document-version diagnostics are ignored. +9. Closing a document clears Rust document and diagnostic state. +10. Shutdown sends exit after the response and force-terminates on timeout. +11. Malformed `Content-Length` produces a transport failure. +12. A partial stdout frame is retained and completed in Rust. +13. Consecutive frames are parsed in order. +14. Dynamic capability registration and unregistration update availability. +15. Workspace replacement stops the old root and clears its state. + +Architecture searches must additionally show no production Swift ownership of +LSP `Content-Length`, raw JSON-RPC request IDs, frame buffers, open-document +sets, pending LSP requests, or language-server child processes. diff --git a/docs/architecture/mac-service-boundaries.md b/docs/architecture/mac-service-boundaries.md index ad4ad8eb..ef121612 100644 --- a/docs/architecture/mac-service-boundaries.md +++ b/docs/architecture/mac-service-boundaries.md @@ -35,7 +35,7 @@ implementations. | `Sources/Lithe/Views/` | SwiftUI/AppKit presentation, input, navigation destinations, and view-local rendering. | | `Sources/Lithe/Models/` | UI-facing models and value types. `AppModel` is the observable aggregate, not the platform composition root. | | `Sources/Lithe/Application/` | Workspace, Document, Git, Search, Java, Terminal, Project History, and UI Feature Models. These coordinate state and user actions. | -| `Sources/Lithe/Services/` | Product workflow orchestration. Language feature routing and LSP/Maven/Run/Debug lifecycles remain Swift workflows behind ports; transport-independent LSP state and normalized results live in Rust. | +| `Sources/Lithe/Services/` | Product workflow orchestration. Language feature routing plus Maven/Run/Debug lifecycles remain Swift workflows; the LSP service is a semantic facade over the Rust runtime. | | `Sources/Lithe/Core/Ports/` | Platform-neutral interfaces for process, terminal, storage, runtime discovery, file operations, watchers, and native UI capabilities. | | `Sources/Lithe/Core/Rust*` | Typed operations and model conversion for the shared Rust JSON contract. | | `Sources/Lithe/Platform/MacOS/` | FSEvents, file operations, persistence, process sessions, PTY, runtime discovery, native UI, shortcuts, and updates. | @@ -50,8 +50,9 @@ or concrete `Mac*` types. They must not construct `Process`, `Pipe`, Services must receive those capabilities through ports. A Service may own a workflow state machine, such as language-provider routing or Maven/Debug lifecycle, but it must not decide how the operating system starts, watches, -stores, or terminates the underlying resource. LSP JSON-RPC state and message -normalization belong to Rust Core, not a language-specific Swift service. +stores, or terminates the underlying resource. The Rust LSP runtime is the +sole owner of its child process, stdio, JSON-RPC state, document versions, +request deadlines, diagnostics, and message normalization. Views receive `AppModel` or a dedicated UI Feature Model. They must not receive concrete workflow services, call the Rust C ABI directly, or construct platform @@ -65,8 +66,9 @@ The Rust Core owns deterministic cross-platform behavior: - Local History metadata and snapshot operations; - Maven descriptor and diagnostic parsing; - Java source structure, code vision, class-name, and run-configuration parsing; -- lightweight language features, LSP JSON-RPC state, framing, capabilities, - diagnostics, and normalized feature results; +- lightweight language features and the complete LSP runtime: process, + stdio/framing, lifecycle, documents, deadlines, capabilities, diagnostics, + provider adapters, and normalized feature results; - request envelopes, cancellation, deadlines, error codes, validation, and stable JSON ordering. @@ -74,9 +76,9 @@ macOS owns the platform side of these capabilities: - workspace selection, FSEvents, atomic/native file operations, permissions, persistence location, and Finder integration; -- language-server/JDK/Maven discovery and process sessions; -- LSP/Java/Maven/Debug process transports, terminal PTY, shell, signals, and - native handles; +- language-server/JDK/Maven discovery and platform environment resolution; +- Java/Maven/Debug process transports, terminal PTY, shell, signals, and + native handles (LSP process transport belongs to Rust); - native window, menu, clipboard, shortcut, installer, and update behavior. ## Verification @@ -95,9 +97,8 @@ with `scripts/verify-shared-contracts.sh` and `scripts/verify-rust-core.sh`. ## Remaining migration work The current boundary is usable and enforced, but it is not a claim that every -workflow has moved into Rust. Language provider routing, LSP process lifecycle, -Maven execution, Java Run/Debug sessions, and terminal session state are still -Swift application workflows using platform ports. Their remaining lifecycle -events should be promoted into shared contracts before Windows implements -equivalent UI. See [`language-tooling.md`](language-tooling.md) for the language -tooling split. +workflow has moved into Rust. Language provider routing remains an application +workflow, while the LSP process lifecycle and protocol state are shared Rust +contracts. Maven execution, Java Run/Debug sessions, and terminal session +state still use Swift platform ports. See +[`language-tooling.md`](language-tooling.md) for the language tooling split. diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index 6c6bed22..48ee0310 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -79,7 +79,7 @@ Moving Rust files must not change JSON command strings, Serde field names, error | UTF-8 file command validation and results | Native file APIs, permissions, and persistence paths | | Git models, validation, parsing, and mutations | Executable environment and credentials | | History metadata and snapshot rules | History storage location and file movement | -| Language provider catalog, lightweight language features, LSP state, Maven, and Java source parsing | Language-server/JDK/Maven discovery and child processes | +| Language provider catalog, lightweight features, complete LSP runtime, Maven, and Java source parsing | Language-server/JDK/Maven discovery; Maven/Debug child processes | | Error codes, cancellation, deadlines, and JSON envelope | PTY/ConPTY, signals, handles, and native UI | The UI must depend on feature models and shared models, not on a concrete @@ -87,8 +87,8 @@ adapter. Core and Services must remain free of AppKit, SwiftUI, Win32, Qt, `Process`, and direct platform file APIs. Language tooling has an additional protocol/application split: Rust owns the -transport-independent LSP state and normalized results, while platform services -own provider routing and process lifecycle. The complete rules are in +complete LSP process/session runtime and normalized results, while platform +services own discovery, provider routing, and UI projection. The complete rules are in [`language-tooling.md`](language-tooling.md). ## Repository hygiene diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 429b4973..d67963e1 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -4,6 +4,7 @@ use serde_json::{json, Value}; pub fn client_initialize(request: ClientInitializeRequest) -> Result { validate_uri(&request.root_uri)?; + let workspace_name = workspace_name_from_uri(&request.root_uri); let mut state = request.state; let id = allocate_request(&mut state, "initialize"); let message = json_rpc_request( @@ -11,15 +12,31 @@ pub fn client_initialize(request: ClientInitializeRequest) -> Result Result Result Result Result<(), CoreError> { } } +fn workspace_name_from_uri(uri: &str) -> String { + let decoded = file_path_from_uri(uri); + decoded + .trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .find(|component| !component.is_empty()) + .unwrap_or("workspace") + .to_string() +} + fn validate_lsp_method(method: &str) -> Result<(), CoreError> { match method { "textDocument/completion" @@ -396,6 +470,9 @@ fn validate_lsp_method(method: &str) -> Result<(), CoreError> { | "textDocument/references" | "textDocument/rename" | "textDocument/formatting" + | "textDocument/inlayHint" + | "textDocument/foldingRange" + | "textDocument/codeLens" | "textDocument/codeAction" | "completionItem/resolve" | "codeAction/resolve" @@ -440,6 +517,13 @@ fn feature_request_params(request: &ClientFeatureRequest) -> Result Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?) + })), + "textDocument/foldingRange" | "textDocument/codeLens" => Ok(json!({ + "textDocument": text_document + })), "textDocument/codeAction" => Ok(json!({ "textDocument": text_document, "range": lsp_range_json(required_range(request)?), @@ -645,6 +729,15 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) .unwrap_or_default() })), + Some("textDocument/inlayHint") => Some(json!({ + "hints": parse_inlay_hints(result) + })), + Some("textDocument/foldingRange") => Some(json!({ + "ranges": parse_folding_ranges(result) + })), + Some("textDocument/codeLens") => Some(json!({ + "lenses": parse_code_lenses(result) + })), Some("textDocument/codeAction") => Some(json!({ "actions": parse_code_actions(result) })), @@ -764,6 +857,100 @@ fn hover_contents(value: &Value) -> Option<(String, bool)> { } } +fn parse_inlay_hints(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|hint| { + Some(LspInlayHintResponse { + position: parse_lsp_position(hint.get("position")?)?, + label: parse_inlay_hint_label(hint.get("label")?)?, + kind: hint.get("kind").and_then(Value::as_i64), + tooltip: completion_documentation(hint.get("tooltip")), + padding_left: hint + .get("paddingLeft") + .and_then(Value::as_bool) + .unwrap_or(false), + padding_right: hint + .get("paddingRight") + .and_then(Value::as_bool) + .unwrap_or(false), + text_edits: hint + .get("textEdits") + .and_then(Value::as_array) + .map(|edits| { + edits.iter().filter_map(parse_lsp_text_edit_value).collect() + }) + .unwrap_or_default(), + data: hint.get("data").cloned(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_inlay_hint_label(value: &Value) -> Option { + match value { + Value::String(label) => Some(label.clone()), + Value::Array(parts) => { + let label = parts + .iter() + .filter_map(|part| part.get("value").and_then(Value::as_str)) + .collect::(); + (!label.is_empty()).then_some(label) + } + _ => None, + } +} + +fn parse_folding_ranges(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|range| { + Some(LspFoldingRangeResponse { + start_line: range.get("startLine")?.as_i64()?, + start_utf16_column: range.get("startCharacter").and_then(Value::as_i64), + end_line: range.get("endLine")?.as_i64()?, + end_utf16_column: range.get("endCharacter").and_then(Value::as_i64), + kind: range + .get("kind") + .and_then(Value::as_str) + .map(str::to_string), + collapsed_text: range + .get("collapsedText") + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_code_lenses(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|lens| { + Some(LspCodeLensResponse { + range: parse_lsp_range(lens.get("range")?)?, + command: lens.get("command").and_then(parse_lsp_command), + data: lens.get("data").cloned(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + fn parse_locations(result: &Value) -> Vec { let values: Vec<&Value> = if let Some(array) = result.as_array() { array.iter().collect() @@ -784,10 +971,13 @@ fn parse_locations(result: &Value) -> Vec { .or_else(|| location.get("targetSelectionRange")) .or_else(|| location.get("targetRange")) .and_then(parse_lsp_range_value)?; + let file_path = file_path_for_uri(uri); + let is_read_only = file_path.is_none(); Some(json!({ - "filePath": file_path_from_uri(uri), + "uri": uri, + "filePath": file_path, "range": range, - "isReadOnly": false, + "isReadOnly": is_read_only, "displayPath": Value::Null })) }) @@ -902,9 +1092,25 @@ fn parse_lsp_position_value(value: &Value) -> Option { } pub(crate) fn file_path_from_uri(uri: &str) -> String { - let path = uri.strip_prefix("file://").unwrap_or(uri); - let mut decoded = Vec::with_capacity(path.len()); - let bytes = path.as_bytes(); + file_path_for_uri(uri).unwrap_or_else(|| percent_decode(uri)) +} + +fn file_path_for_uri(uri: &str) -> Option { + let (scheme, remainder) = uri.split_once(':')?; + if !scheme.eq_ignore_ascii_case("file") { + return None; + } + let path = if remainder.starts_with("///") { + &remainder[2..] + } else { + remainder + }; + Some(percent_decode(path)) +} + +fn percent_decode(value: &str) -> String { + let mut decoded = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() { @@ -919,7 +1125,7 @@ pub(crate) fn file_path_from_uri(uri: &str) -> String { decoded.push(bytes[index]); index += 1; } - String::from_utf8(decoded).unwrap_or_else(|_| path.to_string()) + String::from_utf8(decoded).unwrap_or_else(|_| value.to_string()) } fn hex_value(value: u8) -> Option { @@ -977,6 +1183,20 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { "documentFormattingProvider", "formatting", ); + add_capability(&mut values, capabilities, "inlayHintProvider", "inlayHints"); + add_capability( + &mut values, + capabilities, + "foldingRangeProvider", + "foldingRanges", + ); + add_capability(&mut values, capabilities, "codeLensProvider", "codeLens"); + add_capability( + &mut values, + capabilities, + "workspaceSymbolProvider", + "workspaceSymbols", + ); add_capability( &mut values, capabilities, @@ -1086,7 +1306,11 @@ fn feature_name_for_method(method: &str) -> Option<&'static str> { "textDocument/completion" => Some("completion"), "textDocument/rename" => Some("rename"), "textDocument/formatting" => Some("formatting"), + "textDocument/inlayHint" => Some("inlayHints"), + "textDocument/foldingRange" => Some("foldingRanges"), + "textDocument/codeLens" => Some("codeLens"), "textDocument/codeAction" => Some("codeActions"), + "workspace/symbol" => Some("workspaceSymbols"), "workspace/executeCommand" => Some("executeCommand"), _ => None, } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs new file mode 100644 index 00000000..6b337477 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -0,0 +1,1997 @@ +use super::{ + client_apply_server_message, client_change_document, client_close_document, + client_feature_request_canonical, client_initialize, client_open_document, client_shutdown, + frame_message, parse_server_messages, ClientApplyServerMessageRequest, + ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, + ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, + LspClientDiagnostic, LspClientDocument, LspClientState, LspPosition, LspRange, + ParseServerMessagesRequest, +}; +use crate::lsp::languages::jdt::{ + adapt_start, initialized_notification, virtual_source_resolve_params, workspace_configuration, + JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, VecDeque}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +const DEFAULT_INITIALIZE_TIMEOUT_MS: u64 = 10_000; +const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; +const MONITOR_INTERVAL_MS: u64 = 10; + +static ENGINE: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspLifecycleState { + Created, + ProcessStarting, + Initializing, + Ready, + Stopping, + Stopped, + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StartServerRequest { + pub provider_id: String, + pub executable_path: String, + #[serde(default)] + pub arguments: Vec, + #[serde(default)] + pub environment: BTreeMap, + pub root_uri: String, + pub working_directory: String, + #[serde(default)] + pub initialization_options: Option, + #[serde(default)] + pub runtime_executable_path: Option, + #[serde(default)] + pub cache_directory: Option, + #[serde(default = "default_initialize_timeout")] + pub initialize_timeout_milliseconds: u64, + #[serde(default = "default_request_timeout")] + pub request_timeout_milliseconds: u64, + #[serde(default = "default_shutdown_timeout")] + pub shutdown_timeout_milliseconds: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartServerResponse { + pub session_id: String, + pub state: LspLifecycleState, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncDocumentRequest { + pub session_id: String, + pub uri: String, + pub language_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CloseDocumentRequest { + pub session_id: String, + pub uri: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspSemanticOperation { + Completion, + Hover, + Definition, + Declaration, + TypeDefinition, + References, + Implementation, + Rename, + Formatting, + CodeActions, + ResolveCompletion, + ResolveCodeAction, + ExecuteCommand, + InlayHints, + FoldingRanges, + CodeLens, + VirtualDocument, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SemanticRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + pub operation: LspSemanticOperation, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub virtual_uri: Option, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, + #[serde(default)] + pub code_action: Option, + #[serde(default)] + pub command: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationResponse { + pub operation_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelOperationRequest { + pub session_id: String, + pub operation_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PollEventsResponse { + pub events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRuntimeEvent { + #[serde(rename = "type")] + pub kind: String, + pub sequence: u64, + pub provider_id: String, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspServerInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRuntimeError { + pub code: String, + pub provider_id: String, + pub session_id: String, + pub stage: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub underlying_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub process_exit_code: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineSnapshot { + pub session_id: String, + pub provider_id: String, + pub state: LspLifecycleState, + pub initialized: bool, + pub open_documents: BTreeMap, + pub pending_operation_ids: Vec, + pub diagnostic_versions: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PendingKind { + Initialize, + Feature, + Shutdown, +} + +#[derive(Debug, Clone)] +struct PendingRequest { + kind: PendingKind, + operation_id: Option, + method: String, + document_uri: Option, + created_at: Instant, + deadline: Instant, +} + +struct SessionState { + lifecycle: LspLifecycleState, + client: LspClientState, + pending: BTreeMap, + request_by_operation: BTreeMap, + events: VecDeque, + next_sequence: u64, + initialize_deadline: Option, + shutdown_deadline: Option, + request_timeout: Duration, + shutdown_timeout: Duration, + terminal_event_emitted: bool, +} + +struct RuntimeSession { + id: String, + provider_id: String, + root_uri: String, + state: Mutex, + stdin: Mutex>, + child: Mutex, + active: AtomicBool, +} + +struct LspEngine { + next_session_id: AtomicU64, + next_operation_id: AtomicU64, + sessions: Mutex>>, +} + +fn default_initialize_timeout() -> u64 { + DEFAULT_INITIALIZE_TIMEOUT_MS +} + +fn default_request_timeout() -> u64 { + DEFAULT_REQUEST_TIMEOUT_MS +} + +fn default_shutdown_timeout() -> u64 { + DEFAULT_SHUTDOWN_TIMEOUT_MS +} + +pub fn start_server(request: StartServerRequest) -> Result { + engine().start_server(request) +} + +pub fn stop_server(request: SessionRequest) -> Result<(), CoreError> { + engine().session(&request.session_id)?.stop() +} + +pub fn sync_document(request: SyncDocumentRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .sync_document(request) +} + +pub fn close_document(request: CloseDocumentRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .close_document(&request.uri) +} + +pub fn semantic_request(request: SemanticRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +pub fn cancel_operation(request: CancelOperationRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .cancel_operation(&request.operation_id) +} + +pub fn poll_events(request: SessionRequest) -> Result { + Ok(PollEventsResponse { + events: engine().session(&request.session_id)?.poll_events()?, + }) +} + +pub fn clear_diagnostics(request: SessionRequest) -> Result<(), CoreError> { + engine().session(&request.session_id)?.clear_diagnostics() +} + +pub fn snapshot(request: SessionRequest) -> Result { + engine().session(&request.session_id)?.snapshot() +} + +pub fn destroy_server(request: SessionRequest) -> Result<(), CoreError> { + engine().destroy(&request.session_id) +} + +fn engine() -> &'static LspEngine { + ENGINE.get_or_init(LspEngine::new) +} + +impl LspEngine { + fn new() -> Self { + Self { + next_session_id: AtomicU64::new(1), + next_operation_id: AtomicU64::new(1), + sessions: Mutex::new(BTreeMap::new()), + } + } + + fn next_operation_id(&self) -> String { + format!( + "lsp-operation-{}", + self.next_operation_id.fetch_add(1, Ordering::Relaxed) + ) + } + + fn start_server(&self, request: StartServerRequest) -> Result { + validate_start_request(&request)?; + let session_id = format!( + "lsp-session-{}", + self.next_session_id.fetch_add(1, Ordering::Relaxed) + ); + let workspace_root = PathBuf::from(&request.working_directory); + let data_root = request + .cache_directory + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("lithe-lsp")); + let selected_java_executable = request + .runtime_executable_path + .as_deref() + .map(PathBuf::from) + .or_else(|| java_executable_from_environment(&request.environment)); + let adaptation = adapt_start(&JdtStartContext { + provider_id: request.provider_id.clone(), + workspace_root: workspace_root.clone(), + data_root, + selected_java_executable, + arguments: request.arguments.clone(), + }); + if let Some(directory) = &adaptation.data_directory { + std::fs::create_dir_all(directory).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not create the language-server state directory.", + ) + .with_details(error.to_string()) + })?; + } + + let mut command = Command::new(&request.executable_path); + command + .args(&adaptation.arguments) + .current_dir(&workspace_root) + .envs(&request.environment) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not start the language-server process.", + ) + .with_details(error.to_string()) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Language-server stdin was unavailable.", + ) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Language-server stdout was unavailable.", + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Language-server stderr was unavailable.", + ) + })?; + + let initialize_timeout = Duration::from_millis(request.initialize_timeout_milliseconds); + let request_timeout = Duration::from_millis(request.request_timeout_milliseconds); + let shutdown_timeout = Duration::from_millis(request.shutdown_timeout_milliseconds); + let initialize = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: request.root_uri.clone(), + process_id: Some(std::process::id() as i64), + initialization_options: request.initialization_options, + })?; + let request_id = (initialize.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let session = Arc::new(RuntimeSession { + id: session_id.clone(), + provider_id: request.provider_id, + root_uri: request.root_uri, + state: Mutex::new(SessionState { + lifecycle: LspLifecycleState::Created, + client: initialize.state, + pending: BTreeMap::from([( + request_id, + PendingRequest { + kind: PendingKind::Initialize, + operation_id: None, + method: "initialize".to_string(), + document_uri: None, + created_at: now, + deadline: now + initialize_timeout, + }, + )]), + request_by_operation: BTreeMap::new(), + events: VecDeque::new(), + next_sequence: 1, + initialize_deadline: Some(now + initialize_timeout), + shutdown_deadline: None, + request_timeout, + shutdown_timeout, + terminal_event_emitted: false, + }), + stdin: Mutex::new(Some(stdin)), + child: Mutex::new(child), + active: AtomicBool::new(true), + }); + session.transition(LspLifecycleState::Created, None)?; + session.transition(LspLifecycleState::ProcessStarting, None)?; + session.transition(LspLifecycleState::Initializing, None)?; + + self.lock_sessions()? + .insert(session_id.clone(), session.clone()); + session.spawn_readers(stdout, stderr); + session.spawn_monitor(); + if let Err(error) = session.send_messages(initialize.messages) { + session.fail( + "transportFailed", + "initialize", + "Could not write the initialize request.", + Some(core_error_detail(&error)), + None, + ); + session.kill_process(); + return Err(error); + } + + Ok(StartServerResponse { + session_id, + state: LspLifecycleState::Initializing, + }) + } + + fn session(&self, session_id: &str) -> Result, CoreError> { + self.lock_sessions()? + .get(session_id) + .cloned() + .ok_or_else(|| unknown_session(session_id)) + } + + fn destroy(&self, session_id: &str) -> Result<(), CoreError> { + let session = self.session(session_id)?; + let lifecycle = session.lock_state()?.lifecycle; + if !matches!( + lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A running language-server session cannot be destroyed.", + )); + } + self.lock_sessions()?.remove(session_id); + Ok(()) + } + + fn lock_sessions( + &self, + ) -> Result>>, CoreError> { + self.sessions.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server session registry lock was poisoned.", + ) + }) + } +} + +impl RuntimeSession { + fn sync_document(&self, request: SyncDocumentRequest) -> Result<(), CoreError> { + let uri = request.uri; + let mut messages = Vec::new(); + { + let mut state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + if state.lifecycle == LspLifecycleState::Ready { + let response = if state + .client + .open_documents + .get(&uri) + .is_some_and(|document| document.version > 0) + { + client_change_document(ClientChangeDocumentRequest { + state: state.client.clone(), + uri: uri.clone(), + text: request.text, + })? + } else { + client_open_document(ClientOpenDocumentRequest { + state: state.client.clone(), + uri: uri.clone(), + language_id: request.language_id, + text: request.text, + })? + }; + state.client = response.state; + messages = response.messages; + } else { + // Version zero means the semantic document exists in the Rust + // store but has not yet been opened on the server. The latest + // sync wins until initialize completes. + state.client.diagnostics.remove(&uri); + state.client.diagnostic_versions.remove(&uri); + state.client.open_documents.insert( + uri.clone(), + LspClientDocument { + uri, + language_id: request.language_id, + version: 0, + text: request.text, + }, + ); + } + } + self.send_messages_or_fail(messages, "documentSync") + } + + fn close_document(&self, uri: &str) -> Result<(), CoreError> { + let mut messages = Vec::new(); + let cleared; + { + let mut state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + let Some(document) = state.client.open_documents.get(uri).cloned() else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot close a document that is not owned by the language-server session.", + )); + }; + cleared = state.client.diagnostics.contains_key(uri); + if document.version == 0 { + state.client.open_documents.remove(uri); + state.client.diagnostics.remove(uri); + state.client.diagnostic_versions.remove(uri); + } else { + let response = client_close_document(ClientCloseDocumentRequest { + state: state.client.clone(), + uri: uri.to_string(), + })?; + state.client = response.state; + messages = response.messages; + } + if cleared { + push_diagnostics_event(self, &mut state, uri, None, Vec::new()); + } + } + self.send_messages_or_fail(messages, "documentClose") + } + + fn request(&self, request: SemanticRequest, operation_id: String) -> Result<(), CoreError> { + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let uri = request.uri.clone(); + let method = semantic_method(request.operation); + let required_capability = semantic_capability(request.operation); + if let Some(capability) = required_capability { + if !state + .client + .server_capabilities + .iter() + .any(|candidate| candidate == capability) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The language server did not advertise this capability.", + ) + .with_details(capability)); + } + } + + let response = if request.operation == LspSemanticOperation::VirtualDocument { + let virtual_uri = request.virtual_uri.as_deref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Virtual-document resolution requires virtualUri.", + ) + })?; + let params = virtual_source_resolve_params(&self.provider_id, virtual_uri) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + "The provider cannot resolve this virtual document URI.", + ) + })?; + allocate_raw_request( + state.client.clone(), + method, + json!({ + "command": params.command, + "arguments": params.arguments + }), + )? + } else { + let uri = uri.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This language-server operation requires a document URI.", + ) + })?; + if !state + .client + .open_documents + .get(&uri) + .is_some_and(|document| document.version > 0) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The document is not open in the language server.", + )); + } + client_feature_request_canonical(ClientFeatureRequest { + state: state.client.clone(), + uri, + method: method.to_string(), + position: request.position, + new_name: request.new_name, + range: request.range, + diagnostics: request.diagnostics, + completion_item: request.completion_item, + code_action: request.code_action, + command: request.command, + })? + }; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let pending = PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: method.to_string(), + document_uri: uri, + created_at: now, + deadline: now + state.request_timeout, + }; + state.client = response.state; + state.pending.insert(request_id.clone(), pending); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + if let Err(error) = self.send_messages(messages) { + self.complete_request_with_error( + &request_id, + "transportFailed", + "request", + "Could not write the language-server request.", + Some(core_error_detail(&error)), + None, + ); + self.fail( + "transportFailed", + "request", + "Language-server stdin failed.", + Some(core_error_detail(&error)), + None, + ); + self.kill_process(); + return Err(error); + } + Ok(()) + } + + fn cancel_operation(&self, operation_id: &str) -> Result<(), CoreError> { + let request_id = { + let mut state = self.lock_state()?; + let request_id = state + .request_by_operation + .remove(operation_id) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown pending language-server operation.", + ) + })?; + let pending = state.pending.remove(&request_id).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown pending language-server request.", + ) + })?; + state.client.pending_requests.remove(&request_id); + let error = runtime_error( + self, + "requestCancelled", + "request", + Some(&pending.method), + pending.document_uri.as_deref(), + Some(&request_id), + "Language-server request was cancelled.", + None, + None, + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + request_id + }; + let cancellation = json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": request_id } + }) + .to_string(); + self.send_messages_or_fail(vec![cancellation], "requestCancel") + } + + fn stop(&self) -> Result<(), CoreError> { + let (messages, force_kill) = { + let mut state = self.lock_state()?; + if matches!( + state.lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + return Ok(()); + } + if state.lifecycle == LspLifecycleState::Stopping { + return Ok(()); + } + fail_feature_requests( + self, + &mut state, + "requestCancelled", + "stop", + "Language-server session is stopping.", + None, + ); + clear_runtime_diagnostics(self, &mut state); + transition_locked(self, &mut state, LspLifecycleState::Stopping, None); + state.initialize_deadline = None; + state.shutdown_deadline = Some(Instant::now() + state.shutdown_timeout); + if state.client.initialized { + let response = client_shutdown(ClientShutdownRequest { + state: state.client.clone(), + })?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let shutdown_timeout = state.shutdown_timeout; + state.pending.insert( + request_id, + PendingRequest { + kind: PendingKind::Shutdown, + operation_id: None, + method: "shutdown".to_string(), + document_uri: None, + created_at: now, + deadline: now + shutdown_timeout, + }, + ); + state.client = response.state; + (response.messages, false) + } else { + state.client.pending_requests.clear(); + state.pending.clear(); + (Vec::new(), true) + } + }; + self.send_messages_or_fail(messages, "shutdown")?; + if force_kill { + self.kill_process(); + } + Ok(()) + } + + fn clear_diagnostics(&self) -> Result<(), CoreError> { + let mut state = self.lock_state()?; + clear_runtime_diagnostics(self, &mut state); + Ok(()) + } + + fn poll_events(&self) -> Result, CoreError> { + let mut state = self.lock_state()?; + Ok(state.events.drain(..).collect()) + } + + fn snapshot(&self) -> Result { + let state = self.lock_state()?; + Ok(EngineSnapshot { + session_id: self.id.clone(), + provider_id: self.provider_id.clone(), + state: state.lifecycle, + initialized: state.client.initialized, + open_documents: state.client.open_documents.clone(), + pending_operation_ids: state.request_by_operation.keys().cloned().collect(), + diagnostic_versions: state.client.diagnostic_versions.clone(), + }) + } + + fn spawn_readers( + self: &Arc, + mut stdout: std::process::ChildStdout, + mut stderr: std::process::ChildStderr, + ) { + let output_session = self.clone(); + thread::spawn(move || { + let mut frame_buffer = Vec::new(); + let mut chunk = vec![0_u8; 8 * 1024]; + while output_session.active.load(Ordering::Acquire) { + match stdout.read(&mut chunk) { + Ok(0) => break, + Ok(count) => { + match parse_server_messages(ParseServerMessagesRequest { + buffer: std::mem::take(&mut frame_buffer), + chunk: chunk[..count].to_vec(), + }) { + Ok(parsed) => { + frame_buffer = parsed.buffer; + for message in parsed.messages { + if let Err(error) = + output_session.handle_server_message(message) + { + output_session.fail( + "invalidServerMessage", + "transport", + "Language server sent an invalid message.", + Some(core_error_detail(&error)), + None, + ); + output_session.kill_process(); + return; + } + } + } + Err(error) => { + output_session.fail( + "transportFailed", + "transport", + "Language-server stdout framing failed.", + Some(core_error_detail(&error)), + None, + ); + output_session.kill_process(); + return; + } + } + } + Err(error) => { + output_session.fail( + "transportFailed", + "transport", + "Could not read language-server stdout.", + Some(error.to_string()), + None, + ); + output_session.kill_process(); + return; + } + } + } + }); + + let error_session = self.clone(); + thread::spawn(move || { + let mut chunk = vec![0_u8; 4 * 1024]; + while error_session.active.load(Ordering::Acquire) { + match stderr.read(&mut chunk) { + Ok(0) => break, + Ok(count) => error_session.log( + "warning", + "Language-server stderr", + Some(String::from_utf8_lossy(&chunk[..count]).trim().to_string()), + ), + Err(error) => { + error_session.log( + "warning", + "Could not read language-server stderr", + Some(error.to_string()), + ); + break; + } + } + } + }); + } + + fn spawn_monitor(self: &Arc) { + let session = self.clone(); + thread::spawn(move || { + while session.active.load(Ordering::Acquire) { + let exit = session + .child + .lock() + .ok() + .and_then(|mut child| child.try_wait().ok().flatten()); + if let Some(status) = exit { + session.handle_process_exit(status.code()); + break; + } + session.expire_deadlines(); + thread::sleep(Duration::from_millis(MONITOR_INTERVAL_MS)); + } + }); + } + + fn handle_server_message(&self, message: String) -> Result<(), CoreError> { + let value: Value = serde_json::from_str(&message).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid LSP server JSON message.") + .with_details(error.to_string()) + })?; + + if value.get("method").and_then(Value::as_str) == Some("workspace/configuration") { + if let Some(response) = self.provider_configuration_response(&value)? { + return self.send_messages_or_fail(vec![response], "serverRequest"); + } + } + + let response_id = if value.get("method").is_none() { + lsp_value_id(value.get("id")) + } else { + None + }; + let (known_pending, pending_before, old_capabilities) = { + let state = self.lock_state()?; + let pending = response_id + .as_ref() + .and_then(|id| state.pending.get(id).cloned()); + ( + response_id + .as_ref() + .is_none_or(|id| state.client.pending_requests.contains_key(id)), + pending, + state.client.server_capabilities.clone(), + ) + }; + // A response whose request has timed out, been cancelled, or belongs + // to an older session is intentionally ignored. + if response_id.is_some() && !known_pending { + self.log( + "info", + "Ignored a late language-server response", + response_id, + ); + return Ok(()); + } + + let reduced = { + let state = self.lock_state()?; + client_apply_server_message(ClientApplyServerMessageRequest { + state: state.client.clone(), + message, + })? + }; + let mut outbound = reduced.messages; + let mut flush_documents = false; + let mut fail_initialize: Option<(String, Option)> = None; + { + let mut state = self.lock_state()?; + state.client = reduced.state; + if let Some(request_id) = response_id.as_ref() { + state.client.pending_requests.remove(request_id); + if let Some(pending) = state.pending.remove(request_id) { + if let Some(operation_id) = &pending.operation_id { + state.request_by_operation.remove(operation_id); + } + } + } + + match pending_before.as_ref().map(|pending| pending.kind) { + Some(PendingKind::Initialize) => { + state.initialize_deadline = None; + let server_error = value.get("error").map(Value::to_string); + if server_error.is_some() || !state.client.initialized { + fail_initialize = Some(( + if server_error.is_some() { + "initializeFailed".to_string() + } else { + "invalidServerMessage".to_string() + }, + server_error, + )); + } else { + transition_locked(self, &mut state, LspLifecycleState::Ready, None); + let capabilities = state.client.server_capabilities.clone(); + push_features_event(self, &mut state, capabilities); + if let Some(info) = parse_server_info(&value) { + push_server_info_event(self, &mut state, info); + } + flush_documents = true; + } + } + Some(PendingKind::Feature) => { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let event = reduced + .events + .iter() + .find(|event| event.request_id.as_ref() == response_id.as_ref()); + let error = + event.and_then(|event| event.error.as_ref()).map(|detail| { + runtime_error( + self, + "serverError", + "request", + Some(&pending.method), + pending.document_uri.as_deref(), + response_id.as_deref(), + "Language server returned an error.", + Some(detail), + None, + ) + }); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + event.and_then(|event| event.result.clone()), + error, + ); + } + } + } + Some(PendingKind::Shutdown) => { + // The reducer emits `exit` only after the shutdown response. + state.shutdown_deadline = Some(Instant::now() + state.shutdown_timeout); + } + None => {} + } + + for event in reduced.events { + if event.kind == "diagnostics" { + if let Some(uri) = event.uri.as_deref() { + push_diagnostics_event( + self, + &mut state, + uri, + event.version, + event.diagnostics.unwrap_or_default(), + ); + } + } else if event.kind == "notification" { + push_log_event( + self, + &mut state, + "info", + event + .method + .as_deref() + .unwrap_or("Language-server notification"), + event.result.map(|value| value.to_string()), + ); + } + } + if state.client.server_capabilities != old_capabilities + && state.lifecycle == LspLifecycleState::Ready + { + let capabilities = state.client.server_capabilities.clone(); + push_features_event(self, &mut state, capabilities); + } + } + + if let Some((code, detail)) = fail_initialize { + self.fail( + &code, + "initialize", + "Language-server initialization failed.", + detail, + None, + ); + self.kill_process(); + return Ok(()); + } + if flush_documents { + if let Some(notification) = initialized_notification(&self.provider_id) { + outbound.push( + json!({ + "jsonrpc": "2.0", + "method": notification.method, + "params": notification.params + }) + .to_string(), + ); + } + outbound.extend(self.flush_queued_documents()?); + } + self.send_messages_or_fail(outbound, "serverResponse") + } + + fn provider_configuration_response( + &self, + message: &Value, + ) -> Result, CoreError> { + let Some(id) = message.get("id") else { + return Ok(None); + }; + let items: Vec = message + .get("params") + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|item| WorkspaceConfigurationItem { + scope_uri: item + .get("scopeUri") + .and_then(Value::as_str) + .map(ToString::to_string), + section: item + .get("section") + .and_then(Value::as_str) + .map(ToString::to_string), + }) + .collect(); + let Some(values) = workspace_configuration(&self.provider_id, &items) else { + return Ok(None); + }; + Ok(Some( + serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": id, + "result": values + })) + .map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not encode the provider configuration response.", + ) + .with_details(error.to_string()) + })?, + )) + } + + fn flush_queued_documents(&self) -> Result, CoreError> { + let mut state = self.lock_state()?; + let queued: Vec<_> = state + .client + .open_documents + .values() + .filter(|document| document.version == 0) + .cloned() + .collect(); + let mut messages = Vec::new(); + for document in queued { + let response = client_open_document(ClientOpenDocumentRequest { + state: state.client.clone(), + uri: document.uri, + language_id: document.language_id, + text: document.text, + })?; + state.client = response.state; + messages.extend(response.messages); + } + Ok(messages) + } + + fn expire_deadlines(&self) { + let now = Instant::now(); + let mut cancellations = Vec::new(); + let mut initialize_timeout = false; + let mut shutdown_timeout = false; + if let Ok(mut state) = self.lock_state() { + if state.lifecycle == LspLifecycleState::Initializing + && state + .initialize_deadline + .is_some_and(|deadline| now >= deadline) + { + state.initialize_deadline = None; + initialize_timeout = true; + } + + let expired: Vec<_> = state + .pending + .iter() + .filter(|(_, pending)| { + pending.kind == PendingKind::Feature && now >= pending.deadline + }) + .map(|(id, _)| id.clone()) + .collect(); + for request_id in expired { + let Some(pending) = state.pending.remove(&request_id) else { + continue; + }; + state.client.pending_requests.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + let elapsed = now.saturating_duration_since(pending.created_at); + let error = runtime_error( + self, + "requestTimeout", + "request", + Some(&pending.method), + pending.document_uri.as_deref(), + Some(&request_id), + "Language-server request timed out.", + Some(&format!("elapsedMilliseconds={}", elapsed.as_millis())), + None, + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + cancellations.push(request_id); + } + if state.lifecycle == LspLifecycleState::Stopping + && state + .shutdown_deadline + .is_some_and(|deadline| now >= deadline) + { + state.shutdown_deadline = None; + shutdown_timeout = true; + push_log_event( + self, + &mut state, + "warning", + "Language-server shutdown timed out; forcing termination", + None, + ); + } + } + if !cancellations.is_empty() { + let messages = cancellations + .into_iter() + .map(|id| { + json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": id } + }) + .to_string() + }) + .collect(); + let _ = self.send_messages(messages); + } + if initialize_timeout { + self.fail( + "initializeTimeout", + "initialize", + "Language-server initialization timed out.", + None, + None, + ); + self.kill_process(); + } else if shutdown_timeout { + self.kill_process(); + } + } + + fn handle_process_exit(&self, exit_code: Option) { + self.active.store(false, Ordering::Release); + if let Ok(mut input) = self.stdin.lock() { + *input = None; + } + if let Ok(mut state) = self.lock_state() { + let was_stopping = state.lifecycle == LspLifecycleState::Stopping; + if !state.terminal_event_emitted { + let error = (!was_stopping).then(|| { + runtime_error( + self, + "serverExited", + "process", + None, + None, + None, + "Language-server process exited.", + None, + exit_code, + ) + }); + fail_feature_requests( + self, + &mut state, + "serverExited", + "process", + "Language-server process exited before the request completed.", + exit_code, + ); + clear_runtime_state(self, &mut state); + transition_locked( + self, + &mut state, + if was_stopping { + LspLifecycleState::Stopped + } else { + LspLifecycleState::Failed + }, + error, + ); + state.terminal_event_emitted = true; + } + } + } + + fn complete_request_with_error( + &self, + request_id: &str, + code: &str, + stage: &str, + message: &str, + underlying: Option, + exit_code: Option, + ) { + if let Ok(mut state) = self.lock_state() { + let Some(pending) = state.pending.remove(request_id) else { + return; + }; + state.client.pending_requests.remove(request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + let error = runtime_error( + self, + code, + stage, + Some(&pending.method), + pending.document_uri.as_deref(), + Some(request_id), + message, + underlying.as_deref(), + exit_code, + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + } + } + + fn fail( + &self, + code: &str, + stage: &str, + message: &str, + underlying: Option, + exit_code: Option, + ) { + if let Ok(mut state) = self.lock_state() { + if state.terminal_event_emitted { + return; + } + fail_feature_requests(self, &mut state, code, stage, message, exit_code); + clear_runtime_state(self, &mut state); + let error = runtime_error( + self, + code, + stage, + None, + None, + None, + message, + underlying.as_deref(), + exit_code, + ); + transition_locked(self, &mut state, LspLifecycleState::Failed, Some(error)); + state.terminal_event_emitted = true; + } + } + + fn transition( + &self, + lifecycle: LspLifecycleState, + error: Option, + ) -> Result<(), CoreError> { + let mut state = self.lock_state()?; + transition_locked(self, &mut state, lifecycle, error); + Ok(()) + } + + fn log(&self, level: &str, message: &str, detail: Option) { + if let Ok(mut state) = self.lock_state() { + push_log_event(self, &mut state, level, message, detail); + } + } + + fn send_messages_or_fail(&self, messages: Vec, stage: &str) -> Result<(), CoreError> { + if messages.is_empty() { + return Ok(()); + } + if let Err(error) = self.send_messages(messages) { + self.fail( + "transportFailed", + stage, + "Could not write to language-server stdin.", + Some(core_error_detail(&error)), + None, + ); + self.kill_process(); + return Err(error); + } + Ok(()) + } + + fn send_messages(&self, messages: Vec) -> Result<(), CoreError> { + if messages.is_empty() { + return Ok(()); + } + let mut input = self.stdin.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server stdin lock was poisoned.", + ) + })?; + let input = input.as_mut().ok_or_else(|| { + CoreError::new(ErrorCode::ProcessFailed, "Language-server stdin is closed.") + })?; + for message in messages { + let frame = frame_message(FrameMessageRequest { message })?.frame; + input.write_all(frame.as_bytes()).map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Could not write to language-server stdin.", + ) + .with_details(error.to_string()) + })?; + } + input.flush().map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Could not flush language-server stdin.", + ) + .with_details(error.to_string()) + }) + } + + fn kill_process(&self) { + if let Ok(mut child) = self.child.lock() { + let _ = child.kill(); + } + } + + fn lock_state(&self) -> Result, CoreError> { + self.state.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server session state lock was poisoned.", + ) + }) + } +} + +fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> { + if request.provider_id.trim().is_empty() { + return Err(invalid_field("providerId")); + } + if request.executable_path.trim().is_empty() + || request.executable_path.contains('\0') + || request.working_directory.trim().is_empty() + || request.working_directory.contains('\0') + { + return Err(invalid_field("executablePath/workingDirectory")); + } + if !request.root_uri.contains("://") || request.root_uri.contains('\0') { + return Err(invalid_field("rootUri")); + } + Ok(()) +} + +fn invalid_field(field: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid language-server start request.", + ) + .with_details(field) +} + +fn core_error_detail(error: &CoreError) -> String { + match error.details.as_deref() { + Some(details) if !details.is_empty() => format!("{} ({details})", error.message), + _ => error.message.clone(), + } +} + +fn unknown_session(session_id: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown language-server session.", + ) + .with_details(session_id) +} + +fn ensure_not_terminal(lifecycle: LspLifecycleState) -> Result<(), CoreError> { + if matches!( + lifecycle, + LspLifecycleState::Stopping | LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language-server session is not accepting document changes.", + )) + } else { + Ok(()) + } +} + +fn java_executable_from_environment(environment: &BTreeMap) -> Option { + environment.get("JAVA_HOME").map(|home| { + let executable = if cfg!(windows) { "java.exe" } else { "java" }; + Path::new(home).join("bin").join(executable) + }) +} + +fn semantic_method(operation: LspSemanticOperation) -> &'static str { + match operation { + LspSemanticOperation::Completion => "textDocument/completion", + LspSemanticOperation::Hover => "textDocument/hover", + LspSemanticOperation::Definition => "textDocument/definition", + LspSemanticOperation::Declaration => "textDocument/declaration", + LspSemanticOperation::TypeDefinition => "textDocument/typeDefinition", + LspSemanticOperation::References => "textDocument/references", + LspSemanticOperation::Implementation => "textDocument/implementation", + LspSemanticOperation::Rename => "textDocument/rename", + LspSemanticOperation::Formatting => "textDocument/formatting", + LspSemanticOperation::CodeActions => "textDocument/codeAction", + LspSemanticOperation::ResolveCompletion => "completionItem/resolve", + LspSemanticOperation::ResolveCodeAction => "codeAction/resolve", + LspSemanticOperation::ExecuteCommand | LspSemanticOperation::VirtualDocument => { + "workspace/executeCommand" + } + LspSemanticOperation::InlayHints => "textDocument/inlayHint", + LspSemanticOperation::FoldingRanges => "textDocument/foldingRange", + LspSemanticOperation::CodeLens => "textDocument/codeLens", + } +} + +fn semantic_capability(operation: LspSemanticOperation) -> Option<&'static str> { + match operation { + LspSemanticOperation::Completion => Some("completion"), + LspSemanticOperation::Hover => Some("hover"), + LspSemanticOperation::Definition => Some("definition"), + LspSemanticOperation::Declaration => Some("declaration"), + LspSemanticOperation::TypeDefinition => Some("typeDefinition"), + LspSemanticOperation::References => Some("references"), + LspSemanticOperation::Implementation => Some("implementation"), + LspSemanticOperation::Rename => Some("rename"), + LspSemanticOperation::Formatting => Some("formatting"), + LspSemanticOperation::CodeActions => Some("codeActions"), + LspSemanticOperation::ResolveCompletion => Some("completionResolve"), + LspSemanticOperation::ResolveCodeAction => Some("codeActionResolve"), + LspSemanticOperation::ExecuteCommand | LspSemanticOperation::VirtualDocument => { + Some("executeCommand") + } + LspSemanticOperation::InlayHints => Some("inlayHints"), + LspSemanticOperation::FoldingRanges => Some("foldingRanges"), + LspSemanticOperation::CodeLens => Some("codeLens"), + } +} + +fn allocate_raw_request( + mut state: LspClientState, + method: &str, + params: Value, +) -> Result { + let id = state.next_request_id.to_string(); + state.next_request_id += 1; + state + .pending_requests + .insert(id.clone(), method.to_string()); + let message = serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) + .map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not encode a language-server request.", + ) + .with_details(error.to_string()) + })?; + Ok(super::LspClientResponse { + state, + messages: vec![message], + events: Vec::new(), + }) +} + +fn lsp_value_id(value: Option<&Value>) -> Option { + match value? { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn parse_server_info(message: &Value) -> Option { + let info = message.get("result")?.get("serverInfo")?; + Some(LspServerInfo { + name: info.get("name")?.as_str()?.to_string(), + version: info + .get("version") + .and_then(Value::as_str) + .map(ToString::to_string), + }) +} + +fn transition_locked( + session: &RuntimeSession, + state: &mut SessionState, + lifecycle: LspLifecycleState, + error: Option, +) { + state.lifecycle = lifecycle; + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "stateChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: Some(lifecycle), + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_request_event( + session: &RuntimeSession, + state: &mut SessionState, + operation_id: &str, + method: &str, + result: Option, + error: Option, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "requestCompleted".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id.to_string()), + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result, + error, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_diagnostics_event( + session: &RuntimeSession, + state: &mut SessionState, + uri: &str, + version: Option, + diagnostics: Vec, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "diagnostics".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: Some(uri.to_string()), + version, + diagnostics: Some(diagnostics), + result: None, + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_features_event( + session: &RuntimeSession, + state: &mut SessionState, + capabilities: Vec, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "featuresChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: Some(capabilities), + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_server_info_event(session: &RuntimeSession, state: &mut SessionState, info: LspServerInfo) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "serverInfoChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: None, + server_info: Some(info), + level: None, + message: None, + detail: None, + }); +} + +fn push_log_event( + session: &RuntimeSession, + state: &mut SessionState, + level: &str, + message: &str, + detail: Option, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "log".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: None, + server_info: None, + level: Some(level.to_string()), + message: Some(message.to_string()), + detail: detail.filter(|value| !value.is_empty()), + }); +} + +fn take_sequence(state: &mut SessionState) -> u64 { + let sequence = state.next_sequence; + state.next_sequence += 1; + sequence +} + +fn runtime_error( + session: &RuntimeSession, + code: &str, + stage: &str, + method: Option<&str>, + document_uri: Option<&str>, + request_id: Option<&str>, + message: &str, + underlying: Option<&str>, + process_exit_code: Option, +) -> LspRuntimeError { + LspRuntimeError { + code: code.to_string(), + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + stage: stage.to_string(), + method: method.map(ToString::to_string), + document_uri: document_uri.map(ToString::to_string), + request_id: request_id.map(ToString::to_string), + message: message.to_string(), + underlying_message: underlying.map(ToString::to_string), + process_exit_code, + } +} + +fn fail_feature_requests( + session: &RuntimeSession, + state: &mut SessionState, + code: &str, + stage: &str, + message: &str, + exit_code: Option, +) { + let pending: Vec<_> = state + .pending + .iter() + .filter(|(_, pending)| pending.kind == PendingKind::Feature) + .map(|(request_id, pending)| (request_id.clone(), pending.clone())) + .collect(); + for (request_id, pending) in pending { + state.pending.remove(&request_id); + state.client.pending_requests.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + let error = runtime_error( + session, + code, + stage, + Some(&pending.method), + pending.document_uri.as_deref(), + Some(&request_id), + message, + None, + exit_code, + ); + push_request_event( + session, + state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + } +} + +fn clear_runtime_diagnostics(session: &RuntimeSession, state: &mut SessionState) { + let diagnostics: Vec<_> = state.client.diagnostics.keys().cloned().collect(); + state.client.diagnostics.clear(); + state.client.diagnostic_versions.clear(); + for uri in diagnostics { + push_diagnostics_event(session, state, &uri, None, Vec::new()); + } +} + +fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { + clear_runtime_diagnostics(session, state); + state.client.initialized = false; + state.client.shutdown_requested = false; + state.client.server_capabilities.clear(); + state.client.open_documents.clear(); + state.client.pending_requests.clear(); + state.pending.clear(); + state.request_by_operation.clear(); + state.initialize_deadline = None; + state.shutdown_deadline = None; + push_features_event(session, state, Vec::new()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semantic_operations_are_protocol_methods_but_never_expose_request_ids() { + assert_eq!( + semantic_method(LspSemanticOperation::Definition), + "textDocument/definition" + ); + assert_eq!( + semantic_capability(LspSemanticOperation::VirtualDocument), + Some("executeCommand") + ); + } + + #[test] + fn java_runtime_is_derived_from_the_start_environment() { + let environment = BTreeMap::from([("JAVA_HOME".to_string(), "/jdk".to_string())]); + let path = java_executable_from_environment(&environment).unwrap(); + assert!(path.ends_with(if cfg!(windows) { + "bin/java.exe" + } else { + "bin/java" + })); + } + + #[test] + fn start_contract_rejects_missing_runtime_identity() { + let request = StartServerRequest { + provider_id: String::new(), + executable_path: "/bin/server".to_string(), + arguments: Vec::new(), + environment: BTreeMap::new(), + root_uri: "file:///workspace".to_string(), + working_directory: "/workspace".to_string(), + initialization_options: None, + runtime_executable_path: None, + cache_directory: None, + initialize_timeout_milliseconds: 1, + request_timeout_milliseconds: 1, + shutdown_timeout_milliseconds: 1, + }; + assert!(validate_start_request(&request).is_err()); + } +} diff --git a/rust/lithe-core/src/lsp/interface/host.rs b/rust/lithe-core/src/lsp/interface/host.rs index 28194e07..d1925e6b 100644 --- a/rust/lithe-core/src/lsp/interface/host.rs +++ b/rust/lithe-core/src/lsp/interface/host.rs @@ -310,6 +310,68 @@ mod tests { assert!(host.execute(change).is_err()); } + #[test] + fn host_rejects_diagnostics_from_destroyed_sessions_and_noncurrent_documents() { + let host = LspHost::new(); + let uri = "file:///tmp/project/main.rs"; + let mut first_create = request(LspSessionAction::Create); + first_create.root_uri = Some("file:///tmp/project".to_string()); + let first = host.execute(first_create).unwrap(); + + let mut first_open = request(LspSessionAction::OpenDocument); + first_open.session_id = Some(first.session_id.clone()); + first_open.uri = Some(uri.to_string()); + first_open.language_id = Some("rust".to_string()); + first_open.text = Some("fn first() {}".to_string()); + host.execute(first_open).unwrap(); + + let mut destroy = request(LspSessionAction::Destroy); + destroy.session_id = Some(first.session_id.clone()); + host.execute(destroy).unwrap(); + + let mut old_session_diagnostics = request(LspSessionAction::ApplyServerMessage); + old_session_diagnostics.session_id = Some(first.session_id); + old_session_diagnostics.message = Some( + json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [] + } + }) + .to_string(), + ); + assert!(host.execute(old_session_diagnostics).is_err()); + + let mut second_create = request(LspSessionAction::Create); + second_create.root_uri = Some("file:///tmp/project".to_string()); + let second = host.execute(second_create).unwrap(); + let mut second_open = request(LspSessionAction::OpenDocument); + second_open.session_id = Some(second.session_id.clone()); + second_open.uri = Some(uri.to_string()); + second_open.language_id = Some("rust".to_string()); + second_open.text = Some("fn second() {}".to_string()); + host.execute(second_open).unwrap(); + + let mut stale_diagnostics = request(LspSessionAction::ApplyServerMessage); + stale_diagnostics.session_id = Some(second.session_id); + stale_diagnostics.message = Some( + json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 2, + "diagnostics": [] + } + }) + .to_string(), + ); + assert!(host.execute(stale_diagnostics).unwrap().events.is_empty()); + } + #[test] fn session_requests_reject_unknown_fields() { let error = serde_json::from_value::(json!({ diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 26d6c4d3..dbc34225 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -1,11 +1,13 @@ //! Standard LSP contracts and the stateful client/session implementation. mod client; +mod engine; mod host; mod transport; mod types; pub(crate) use client::*; +pub(crate) use engine::*; pub(crate) use host::{ execute as session_execute_canonical, LspSessionCommandRequest, LspSessionResponse, }; diff --git a/rust/lithe-core/src/lsp/interface/transport.rs b/rust/lithe-core/src/lsp/interface/transport.rs index 4719caaf..8d8b873f 100644 --- a/rust/lithe-core/src/lsp/interface/transport.rs +++ b/rust/lithe-core/src/lsp/interface/transport.rs @@ -1,6 +1,9 @@ use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; +const MAX_HEADER_BYTES: usize = 64 * 1024; +const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FrameMessageRequest { @@ -51,22 +54,34 @@ pub fn parse_server_messages( buffer.extend(request.chunk); let mut messages = Vec::new(); - while let Some(header_end) = find_header_end(&buffer) { - let header = String::from_utf8_lossy(&buffer[..header_end]); - let Some(content_length) = content_length_from_header(&header) else { - buffer.drain(..header_end + 4); - continue; + loop { + let Some(header_end) = find_header_end(&buffer) else { + if buffer.len() > MAX_HEADER_BYTES { + return Err(transport_error("LSP header exceeded the maximum size.")); + } + break; }; + if header_end > MAX_HEADER_BYTES { + return Err(transport_error("LSP header exceeded the maximum size.")); + } + let header = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = content_length_from_header(&header)?; + if content_length > MAX_MESSAGE_BYTES { + return Err(transport_error("LSP message exceeded the maximum size.")); + } let body_start = header_end + 4; - let body_end = body_start + content_length; + let body_end = body_start + .checked_add(content_length) + .ok_or_else(|| transport_error("LSP Content-Length overflowed."))?; if buffer.len() < body_end { break; } let body = buffer[body_start..body_end].to_vec(); buffer.drain(..body_end); - if let Ok(message) = String::from_utf8(body) { - messages.push(message); - } + let message = String::from_utf8(body).map_err(|error| { + transport_error("LSP message body was not valid UTF-8.").with_details(error.to_string()) + })?; + messages.push(message); } Ok(ParseServerMessagesResponse { buffer, messages }) @@ -76,13 +91,53 @@ fn find_header_end(buffer: &[u8]) -> Option { buffer.windows(4).position(|window| window == b"\r\n\r\n") } -fn content_length_from_header(header: &str) -> Option { - header.lines().find_map(|line| { +fn content_length_from_header(header: &str) -> Result { + let value = header.lines().find_map(|line| { let (name, value) = line.split_once(':')?; if name.trim().eq_ignore_ascii_case("content-length") { - value.trim().parse().ok() + Some(value.trim()) } else { None } + }); + let Some(value) = value else { + return Err(transport_error("LSP frame did not contain Content-Length.")); + }; + value.parse::().map_err(|error| { + transport_error("LSP Content-Length was not a valid non-negative integer.") + .with_details(error.to_string()) }) } + +fn transport_error(message: &str) -> CoreError { + CoreError::new(ErrorCode::ParseFailed, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_content_length_is_a_transport_error() { + let missing = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: b"Content-Type: application/json\r\n\r\n{}".to_vec(), + }); + assert!(missing.is_err()); + + let invalid = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: b"Content-Length: nope\r\n\r\n{}".to_vec(), + }); + assert!(invalid.is_err()); + } + + #[test] + fn invalid_utf8_body_is_a_transport_error() { + let result = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: b"Content-Length: 1\r\n\r\n\xff".to_vec(), + }); + assert!(result.is_err()); + } +} diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index d887371b..5ecc17c0 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -23,6 +23,38 @@ pub struct LspTextEditResponse { pub new_text: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspInlayHintResponse { + pub position: LspPositionResponse, + pub label: String, + pub kind: Option, + pub tooltip: Option, + pub padding_left: bool, + pub padding_right: bool, + pub text_edits: Vec, + pub data: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspFoldingRangeResponse { + pub start_line: i64, + pub start_utf16_column: Option, + pub end_line: i64, + pub end_utf16_column: Option, + pub kind: Option, + pub collapsed_text: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspCodeLensResponse { + pub range: LspRangeResponse, + pub command: Option, + pub data: Option, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspRangeResponse { @@ -54,6 +86,8 @@ pub struct LspClientState { pub pending_requests: BTreeMap, #[serde(default)] pub diagnostics: BTreeMap>, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub diagnostic_versions: BTreeMap, } impl Default for LspClientState { @@ -66,6 +100,7 @@ impl Default for LspClientState { open_documents: BTreeMap::new(), pending_requests: BTreeMap::new(), diagnostics: BTreeMap::new(), + diagnostic_versions: BTreeMap::new(), } } } @@ -206,6 +241,8 @@ pub struct LspClientEvent { #[serde(skip_serializing_if = "Option::is_none")] pub uri: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub diagnostics: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs new file mode 100644 index 00000000..b4a89561 --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -0,0 +1,524 @@ +//! JDT LS-specific policy kept outside the generic LSP client and transport. +//! +//! The adapter is deliberately pure: it describes launch arguments, provider +//! notifications, configuration responses, and virtual-source requests. The +//! process engine remains responsible for creating directories and performing +//! all I/O. + +#![allow(dead_code)] // This module is an engine adapter seam; integration is intentionally separate. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const JAVA_PROVIDER_ID: &str = "java"; +const JDT_URI_SCHEME: &str = "jdt"; +const JDTLS_DATA_DIRECTORY: &str = "jdtls"; +const JAVA_DECOMPILE_COMMAND: &str = "java.decompile"; +const DID_CHANGE_CONFIGURATION_METHOD: &str = "workspace/didChangeConfiguration"; + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JdtStartContext { + pub provider_id: String, + pub workspace_root: PathBuf, + /// Engine-owned cache/state root. The adapter only derives a child path. + pub data_root: PathBuf, + #[serde(default)] + pub selected_java_executable: Option, + #[serde(default)] + pub arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JdtStartAdaptation { + pub arguments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_directory: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspaceConfigurationItem { + #[serde(default)] + pub scope_uri: Option, + #[serde(default)] + pub section: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderNotification { + pub method: String, + pub params: Value, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderLocation { + pub uri: String, + #[serde(default)] + pub is_read_only: bool, + #[serde(default)] + pub display_path: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExecuteCommandParams { + pub command: String, + pub arguments: Vec, +} + +/// Applies the JDT LS-owned part of a provider start plan. +/// +/// `data_directory` is returned to the engine as a directory requirement; this +/// function never creates it. Arguments owned by this adapter are replaced so +/// repeated adaptation is deterministic and cannot leave two `-data` roots. +pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { + if !is_java_provider(&context.provider_id) { + return JdtStartAdaptation { + arguments: context.arguments.clone(), + data_directory: None, + }; + } + + let data_directory = context + .data_root + .join(JDTLS_DATA_DIRECTORY) + .join(workspace_key(&context.workspace_root)); + let mut arguments = without_jdt_owned_arguments(&context.arguments); + if let Some(java_executable) = &context.selected_java_executable { + arguments.push("--java-executable".to_string()); + arguments.push(java_executable.to_string_lossy().into_owned()); + } + arguments.extend([ + "--jvm-arg=-Xms256m".to_string(), + "--jvm-arg=-Xmx1024m".to_string(), + "-data".to_string(), + data_directory.to_string_lossy().into_owned(), + ]); + + JdtStartAdaptation { + arguments, + data_directory: Some(data_directory), + } +} + +/// Returns JDT LS configuration values in the same order as the requested +/// `workspace/configuration` items. `None` delegates non-Java providers to the +/// generic engine behavior. +pub(crate) fn workspace_configuration( + provider_id: &str, + items: &[WorkspaceConfigurationItem], +) -> Option> { + if !is_java_provider(provider_id) { + return None; + } + Some( + items + .iter() + .map(|item| java_configuration_for_section(item.section.as_deref())) + .collect(), + ) +} + +/// Notification the engine sends after the generic `initialized` handshake. +pub(crate) fn initialized_notification(provider_id: &str) -> Option { + is_java_provider(provider_id).then(|| ProviderNotification { + method: DID_CHANGE_CONFIGURATION_METHOD.to_string(), + params: json!({ + "settings": java_settings() + }), + }) +} + +/// Marks JDT virtual locations as read-only and gives them a source-like path. +/// File locations and locations from other providers pass through unchanged. +pub(crate) fn normalize_location( + provider_id: &str, + mut location: ProviderLocation, +) -> ProviderLocation { + if is_java_provider(provider_id) && has_uri_scheme(&location.uri, JDT_URI_SCHEME) { + location.is_read_only = true; + location.display_path = jdt_display_path(&location.uri); + } + location +} + +/// Converts a JDT virtual URI into `workspace/executeCommand` parameters. The +/// generic engine owns request IDs and JSON-RPC framing. +pub(crate) fn virtual_source_resolve_params( + provider_id: &str, + uri: &str, +) -> Option { + if !is_java_provider(provider_id) || !has_uri_scheme(uri, JDT_URI_SCHEME) { + return None; + } + Some(ExecuteCommandParams { + command: JAVA_DECOMPILE_COMMAND.to_string(), + arguments: vec![json!(uri)], + }) +} + +fn is_java_provider(provider_id: &str) -> bool { + provider_id.trim().eq_ignore_ascii_case(JAVA_PROVIDER_ID) +} + +fn without_jdt_owned_arguments(arguments: &[String]) -> Vec { + let mut retained = Vec::with_capacity(arguments.len()); + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + let owns_following_value = argument == "--java-executable" || argument == "-data"; + let owns_inline_value = argument.starts_with("--java-executable=") + || argument.starts_with("-data=") + || argument.starts_with("--jvm-arg=-Xms") + || argument.starts_with("--jvm-arg=-Xmx"); + if owns_following_value { + index += usize::from(index + 1 < arguments.len()) + 1; + } else { + if !owns_inline_value { + retained.push(argument.clone()); + } + index += 1; + } + } + retained +} + +fn java_settings() -> Value { + json!({ + "java": { + "inlayHints": { + "parameterNames": { + "enabled": "all" + } + } + } + }) +} + +fn java_configuration_for_section(section: Option<&str>) -> Value { + match section { + Some("java") => json!({ + "inlayHints": { + "parameterNames": { + "enabled": "all" + } + } + }), + Some("java.inlayHints") => json!({ + "parameterNames": { + "enabled": "all" + } + }), + Some("java.inlayHints.parameterNames") => json!({ "enabled": "all" }), + Some("java.inlayHints.parameterNames.enabled") => json!("all"), + _ => Value::Null, + } +} + +fn workspace_key(workspace_root: &Path) -> String { + let identity = normalized_workspace_identity(workspace_root); + let digest = Sha256::digest(identity.as_bytes()); + let mut key = String::with_capacity(digest.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + key.push(HEX[(byte >> 4) as usize] as char); + key.push(HEX[(byte & 0x0f) as usize] as char); + } + key +} + +fn normalized_workspace_identity(workspace_root: &Path) -> String { + let raw = workspace_root.to_string_lossy().replace('\\', "/"); + let bytes = raw.as_bytes(); + let has_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + let is_unc = raw.starts_with("//"); + let (prefix, remainder, is_absolute, protected_components, fold_case) = if has_drive { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let remainder = &raw[2..]; + ( + format!("{drive}:"), + remainder, + remainder.starts_with('/'), + 0, + true, + ) + } else if is_unc { + ("//".to_string(), raw.trim_start_matches('/'), true, 2, true) + } else if raw.starts_with('/') { + ("/".to_string(), raw.trim_start_matches('/'), true, 0, false) + } else { + (String::new(), raw.as_str(), false, 0, false) + }; + + let mut components: Vec = Vec::new(); + for component in remainder.split('/') { + match component { + "" | "." => {} + ".." => { + if components.len() > protected_components + && components.last().is_some_and(|value| value != "..") + { + components.pop(); + } else if !is_absolute { + components.push("..".to_string()); + } + } + value => components.push(if fold_case { + value.to_lowercase() + } else { + value.to_string() + }), + } + } + + let joined = components.join("/"); + match (prefix.as_str(), joined.is_empty(), is_absolute) { + ("", true, _) => ".".to_string(), + ("/", true, _) => "/".to_string(), + ("//", true, _) => "//".to_string(), + (prefix, true, _) => prefix.to_string(), + ("", false, _) => joined, + ("/", false, _) => format!("/{joined}"), + ("//", false, _) => format!("//{joined}"), + (prefix, false, true) => format!("{prefix}/{joined}"), + (prefix, false, false) => format!("{prefix}{joined}"), + } +} + +fn has_uri_scheme(uri: &str, expected: &str) -> bool { + uri.split_once(':') + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case(expected)) +} + +fn jdt_display_path(uri: &str) -> Option { + let (_, remainder) = uri.split_once(':')?; + let path = if let Some(with_authority) = remainder.strip_prefix("//") { + with_authority + .find('/') + .map(|index| &with_authority[index + 1..])? + } else { + remainder.trim_start_matches('/') + }; + let path = path.split_once(['?', '#']).map_or(path, |(value, _)| value); + let decoded = percent_decode(path); + let mut components: Vec<_> = decoded + .split('/') + .filter(|component| !component.is_empty()) + .map(str::to_string) + .collect(); + let last = components.last_mut()?; + if let Some(class_name) = last.strip_suffix(".class") { + *last = format!("{class_name}.java"); + } + Some(components.join("/")) +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn java_start_context() -> JdtStartContext { + JdtStartContext { + provider_id: "java".to_string(), + workspace_root: PathBuf::from("/workspace/project"), + data_root: PathBuf::from("/cache/Lithe"), + selected_java_executable: Some(PathBuf::from("/jdk/bin/java")), + arguments: vec!["--stdio".to_string()], + } + } + + #[test] + fn java_start_adds_runtime_memory_and_unique_data_arguments() { + let context = java_start_context(); + let adapted = adapt_start(&context); + let data_directory = adapted.data_directory.as_ref().unwrap(); + + assert_eq!( + data_directory.parent().unwrap(), + Path::new("/cache/Lithe/jdtls") + ); + assert_eq!( + data_directory.file_name().unwrap().to_string_lossy().len(), + 64 + ); + assert_eq!( + adapted.arguments, + vec![ + "--stdio", + "--java-executable", + "/jdk/bin/java", + "--jvm-arg=-Xms256m", + "--jvm-arg=-Xmx1024m", + "-data", + data_directory.to_string_lossy().as_ref() + ] + ); + } + + #[test] + fn java_start_replaces_adapter_owned_arguments_and_is_stable() { + let mut context = java_start_context(); + context.arguments = vec![ + "--java-executable".to_string(), + "/old/java".to_string(), + "--jvm-arg=-Xms2g".to_string(), + "--jvm-arg=-Xmx4g".to_string(), + "--jvm-arg=-Duser.language=en".to_string(), + "-data".to_string(), + "/old/data".to_string(), + ]; + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + + assert_eq!(first, second); + assert!(first + .arguments + .contains(&"--jvm-arg=-Duser.language=en".to_string())); + assert!(!first.arguments.contains(&"/old/java".to_string())); + assert!(!first.arguments.contains(&"/old/data".to_string())); + } + + #[test] + fn workspace_identity_is_lexical_cross_platform_and_unique() { + assert_eq!( + workspace_key(Path::new(r"C:\Users\Ada\Project\.\src\..")), + workspace_key(Path::new("c:/users/ada/project")) + ); + assert_eq!( + workspace_key(Path::new("/workspace/project/./src/..")), + workspace_key(Path::new("/workspace/project")) + ); + assert_ne!( + workspace_key(Path::new("/workspace/project")), + workspace_key(Path::new("/workspace/other")) + ); + } + + #[test] + fn non_java_start_is_a_generic_noop() { + let mut context = java_start_context(); + context.provider_id = "rust".to_string(); + let adapted = adapt_start(&context); + + assert_eq!(adapted.arguments, context.arguments); + assert_eq!(adapted.data_directory, None); + assert!(workspace_configuration("rust", &[]).is_none()); + assert!(initialized_notification("rust").is_none()); + assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); + let location = ProviderLocation { + uri: "jdt://contents/A.class".to_string(), + is_read_only: false, + display_path: None, + }; + assert_eq!(normalize_location("rust", location.clone()), location); + } + + #[test] + fn java_workspace_configuration_matches_each_section_shape() { + let items = [ + "java", + "java.inlayHints", + "java.inlayHints.parameterNames", + "java.inlayHints.parameterNames.enabled", + "java.unknown", + ] + .map(|section| WorkspaceConfigurationItem { + scope_uri: Some("file:///workspace/project".to_string()), + section: Some(section.to_string()), + }); + let values = workspace_configuration("java", &items).unwrap(); + + assert_eq!(values[0]["inlayHints"]["parameterNames"]["enabled"], "all"); + assert_eq!(values[1]["parameterNames"]["enabled"], "all"); + assert_eq!(values[2]["enabled"], "all"); + assert_eq!(values[3], "all"); + assert_eq!(values[4], Value::Null); + } + + #[test] + fn java_initialized_notification_publishes_inlay_settings() { + let notification = initialized_notification("JAVA").unwrap(); + + assert_eq!(notification.method, "workspace/didChangeConfiguration"); + assert_eq!( + notification.params["settings"]["java"]["inlayHints"]["parameterNames"]["enabled"], + "all" + ); + } + + #[test] + fn jdt_location_is_read_only_with_a_source_display_path() { + let location = normalize_location( + "java", + ProviderLocation { + uri: "jdt://contents/java.base/java/util/Map%24Entry.class?=demo".to_string(), + is_read_only: false, + display_path: None, + }, + ); + + assert!(location.is_read_only); + assert_eq!( + location.display_path.as_deref(), + Some("java.base/java/util/Map$Entry.java") + ); + + let unchanged = normalize_location( + "java", + ProviderLocation { + uri: "file:///workspace/Main.java".to_string(), + is_read_only: false, + display_path: Some("Main.java".to_string()), + }, + ); + assert!(!unchanged.is_read_only); + assert_eq!(unchanged.display_path.as_deref(), Some("Main.java")); + } + + #[test] + fn virtual_source_uses_java_decompile_execute_command_params() { + let uri = "jdt://contents/java.base/java/lang/String.class"; + let params = virtual_source_resolve_params("java", uri).unwrap(); + let encoded = serde_json::to_value(params).unwrap(); + + assert_eq!(encoded["command"], "java.decompile"); + assert_eq!(encoded["arguments"], json!([uri])); + assert!(virtual_source_resolve_params("java", "file:///tmp/String.java").is_none()); + } +} diff --git a/rust/lithe-core/src/lsp/languages/mod.rs b/rust/lithe-core/src/lsp/languages/mod.rs index cb9bd6d0..c77b5551 100644 --- a/rust/lithe-core/src/lsp/languages/mod.rs +++ b/rust/lithe-core/src/lsp/languages/mod.rs @@ -1,6 +1,7 @@ //! Dynamic provider metadata and host-model adapters for individual languages. mod catalog; +pub(crate) mod jdt; pub(crate) mod swift; pub(crate) use catalog::*; diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 59af92c8..c62ad6e7 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -396,6 +396,10 @@ fn file_uri_paths_decode_spaces_and_utf8_characters() { file_path_from_uri("file:///tmp/go%20project/%E4%B8%AD%E6%96%87/main.go"), "/tmp/go project/中文/main.go" ); + assert_eq!( + file_path_from_uri("file://server/share/Main.java"), + "//server/share/Main.java" + ); } #[test] @@ -420,17 +424,51 @@ fn client_core_initializes_and_applies_server_capabilities() { initialize_message["params"]["rootUri"], "file:///tmp/project" ); + assert_eq!(initialize_message["params"]["clientInfo"]["name"], "Lithe"); + assert_eq!( + initialize_message["params"]["clientInfo"]["version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + initialize_message["params"]["workspaceFolders"], + json!([{ + "uri": "file:///tmp/project", + "name": "project" + }]) + ); let client_capabilities = &initialize_message["params"]["capabilities"]; assert_eq!(client_capabilities["workspace"]["configuration"], true); + assert_eq!(client_capabilities["workspace"]["applyEdit"], true); + assert_eq!(client_capabilities["workspace"]["workspaceFolders"], true); + assert_eq!( + client_capabilities["workspace"]["symbol"]["dynamicRegistration"], + true + ); assert_eq!( client_capabilities["textDocument"]["completion"]["completionItem"]["snippetSupport"], - false + true + ); + assert_eq!( + client_capabilities["textDocument"]["completion"]["completionItem"]["resolveSupport"] + ["properties"], + json!(["detail", "documentation", "textEdit", "additionalTextEdits"]) ); assert_eq!( initialize_message["params"]["initializationOptions"]["ui.semanticTokens"], true ); - assert!(client_capabilities["workspace"].get("applyEdit").is_none()); + assert_eq!( + client_capabilities["textDocument"]["inlayHint"]["dynamicRegistration"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["foldingRange"]["dynamicRegistration"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["codeLens"]["dynamicRegistration"], + true + ); assert!(client_capabilities["textDocument"]["synchronization"] .get("didSave") .is_none()); @@ -438,6 +476,10 @@ fn client_core_initializes_and_applies_server_capabilities() { client_capabilities["textDocument"]["publishDiagnostics"]["relatedInformation"], true ); + assert_eq!( + client_capabilities["textDocument"]["publishDiagnostics"]["versionSupport"], + true + ); assert_eq!( client_capabilities["textDocument"]["publishDiagnostics"]["tagSupport"]["valueSet"], json!([1, 2]) @@ -454,7 +496,11 @@ fn client_core_initializes_and_applies_server_capabilities() { "definitionProvider": true, "hoverProvider": true, "completionProvider": { "resolveProvider": true }, - "codeActionProvider": { "resolveProvider": true } + "codeActionProvider": { "resolveProvider": true }, + "inlayHintProvider": true, + "foldingRangeProvider": {}, + "codeLensProvider": { "resolveProvider": false }, + "workspaceSymbolProvider": true } } }"# @@ -472,6 +518,17 @@ fn client_core_initializes_and_applies_server_capabilities() { .state .server_capabilities .contains(&"completionResolve".to_string())); + for feature in [ + "inlayHints", + "foldingRanges", + "codeLens", + "workspaceSymbols", + ] { + assert!(applied + .state + .server_capabilities + .contains(&feature.to_string())); + } assert_eq!(applied.messages.len(), 1); let initialized_notification: Value = serde_json::from_str(&applied.messages[0]).expect("initialized JSON"); @@ -578,14 +635,38 @@ fn client_core_closes_open_documents() { text: "package main\n".to_string(), }) .unwrap(); + let diagnosed = client_apply_server_message(ClientApplyServerMessageRequest { + state: opened.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 7 } + }, + "message": "Example diagnostic" + }] + } + }) + .to_string(), + }) + .unwrap(); + assert!(diagnosed.state.diagnostics.contains_key(uri)); + assert_eq!(diagnosed.state.diagnostic_versions.get(uri), Some(&1)); let closed = client_close_document(ClientCloseDocumentRequest { - state: opened.state, + state: diagnosed.state, uri: uri.to_string(), }) .unwrap(); assert!(!closed.state.open_documents.contains_key(uri)); + assert!(!closed.state.diagnostics.contains_key(uri)); + assert!(!closed.state.diagnostic_versions.contains_key(uri)); assert_eq!(closed.messages.len(), 1); let did_close: Value = serde_json::from_str(&closed.messages[0]).unwrap(); assert_eq!( @@ -609,6 +690,152 @@ fn client_core_closes_open_documents() { assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); } +#[test] +fn client_core_ignores_diagnostics_for_unopened_or_stale_document_versions() { + let uri = "file:///tmp/project/main.rs"; + let unopened = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [] + } + }) + .to_string(), + }) + .unwrap(); + assert!(unopened.events.is_empty()); + assert!(!unopened.state.diagnostics.contains_key(uri)); + + let opened = client_open_document(ClientOpenDocumentRequest { + state: unopened.state, + uri: uri.to_string(), + language_id: "rust".to_string(), + text: "fn main() {}\n".to_string(), + }) + .unwrap(); + let changed = client_change_document(ClientChangeDocumentRequest { + state: opened.state, + uri: uri.to_string(), + text: "fn main() { launch(); }\n".to_string(), + }) + .unwrap(); + + for stale_version in [1, 3] { + let stale = client_apply_server_message(ClientApplyServerMessageRequest { + state: changed.state.clone(), + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": stale_version, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 2 } + }, + "message": "Stale diagnostic" + }] + } + }) + .to_string(), + }) + .unwrap(); + assert!(stale.events.is_empty()); + assert!(!stale.state.diagnostics.contains_key(uri)); + } + + let current = client_apply_server_message(ClientApplyServerMessageRequest { + state: changed.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 2, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 2 } + }, + "message": "Current diagnostic" + }] + } + }) + .to_string(), + }) + .unwrap(); + assert_eq!(current.events.len(), 1); + assert_eq!(current.events[0].version, Some(2)); + assert_eq!(current.state.diagnostic_versions.get(uri), Some(&2)); + + let stale_after_current = client_apply_server_message(ClientApplyServerMessageRequest { + state: current.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [] + } + }) + .to_string(), + }) + .unwrap(); + assert!(stale_after_current.events.is_empty()); + assert_eq!( + stale_after_current.state.diagnostics[uri][0].message, + "Current diagnostic" + ); + assert_eq!( + stale_after_current.state.diagnostic_versions.get(uri), + Some(&2) + ); + + let unversioned = client_apply_server_message(ClientApplyServerMessageRequest { + state: stale_after_current.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "diagnostics": [] + } + }) + .to_string(), + }) + .unwrap(); + assert_eq!(unversioned.events.len(), 1); + assert_eq!(unversioned.events[0].version, None); + assert!(!unversioned.state.diagnostic_versions.contains_key(uri)); + assert!(serde_json::to_value(&unversioned.events[0]) + .unwrap() + .get("version") + .is_none()); +} + +#[test] +fn client_state_keeps_legacy_diagnostics_shape_compatible() { + let uri = "file:///tmp/project/main.rs"; + let state: LspClientState = serde_json::from_value(json!({ + "diagnostics": { + uri: [] + } + })) + .unwrap(); + + assert!(state.diagnostics.contains_key(uri)); + assert!(state.diagnostic_versions.is_empty()); + let serialized = serde_json::to_value(state).unwrap(); + assert!(serialized["diagnostics"][uri].is_array()); + assert!(serialized.get("diagnosticVersions").is_none()); +} + #[test] fn client_core_waits_for_shutdown_response_before_exiting() { let mut state = LspClientState { @@ -1132,16 +1359,238 @@ fn client_core_shapes_feature_responses_for_swift_models() { assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); } +#[test] +fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() { + let uri = "file:///tmp/project/Main.java"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "java".to_string(), + text: "class Main {}\n".to_string(), + }) + .unwrap(); + + let inlay_request = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: uri.to_string(), + method: "textDocument/inlayHint".to_string(), + position: None, + new_name: None, + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 20, + utf16_column: 0, + }, + }), + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let request_json: Value = serde_json::from_str(&inlay_request.messages[0]).unwrap(); + assert_eq!(request_json["method"], "textDocument/inlayHint"); + assert_eq!(request_json["params"]["range"]["end"]["line"], 20); + + let inlay_response = client_apply_server_message(ClientApplyServerMessageRequest { + state: inlay_request.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": [{ + "position": { "line": 4, "character": 12 }, + "label": [{ "value": "parameter" }, { "value": ":" }], + "kind": 2, + "tooltip": { "kind": "markdown", "value": "Parameter name" }, + "paddingLeft": true, + "paddingRight": false, + "textEdits": [{ + "range": { + "start": { "line": 4, "character": 12 }, + "end": { "line": 4, "character": 12 } + }, + "newText": "parameter: " + }], + "data": { "id": "hint-1" } + }] + }) + .to_string(), + }) + .unwrap(); + let hint = &inlay_response.events[0].result.as_ref().unwrap()["hints"][0]; + assert_eq!(hint["position"]["utf16Column"], 12); + assert_eq!(hint["label"], "parameter:"); + assert_eq!(hint["kind"], 2); + assert_eq!(hint["tooltip"], "Parameter name"); + assert_eq!(hint["paddingLeft"], true); + assert_eq!(hint["textEdits"][0]["range"]["start"]["utf16Column"], 12); + assert_eq!(hint["data"]["id"], "hint-1"); + + let folding_request = client_feature_request(ClientFeatureRequest { + state: inlay_response.state, + uri: uri.to_string(), + method: "textDocument/foldingRange".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let request_json: Value = serde_json::from_str(&folding_request.messages[0]).unwrap(); + assert_eq!(request_json["method"], "textDocument/foldingRange"); + assert_eq!(request_json["params"]["textDocument"]["uri"], uri); + let folding_response = client_apply_server_message(ClientApplyServerMessageRequest { + state: folding_request.state, + message: json!({ + "jsonrpc": "2.0", + "id": "2", + "result": [{ + "startLine": 2, + "startCharacter": 4, + "endLine": 8, + "endCharacter": 1, + "kind": "region", + "collapsedText": "methods" + }] + }) + .to_string(), + }) + .unwrap(); + let range = &folding_response.events[0].result.as_ref().unwrap()["ranges"][0]; + assert_eq!(range["startLine"], 2); + assert_eq!(range["startUtf16Column"], 4); + assert_eq!(range["endLine"], 8); + assert_eq!(range["endUtf16Column"], 1); + assert_eq!(range["kind"], "region"); + assert_eq!(range["collapsedText"], "methods"); + + let code_lens_request = client_feature_request(ClientFeatureRequest { + state: folding_response.state, + uri: uri.to_string(), + method: "textDocument/codeLens".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let code_lens_response = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_lens_request.state, + message: json!({ + "jsonrpc": "2.0", + "id": "3", + "result": [{ + "range": { + "start": { "line": 3, "character": 2 }, + "end": { "line": 3, "character": 7 } + }, + "command": { + "title": "Run test", + "command": "java.test.run", + "arguments": [{ "test": "MainTest" }] + }, + "data": { "id": "lens-1" } + }] + }) + .to_string(), + }) + .unwrap(); + let lens = &code_lens_response.events[0].result.as_ref().unwrap()["lenses"][0]; + assert_eq!(lens["range"]["start"]["utf16Column"], 2); + assert_eq!(lens["command"]["command"], "java.test.run"); + assert_eq!(lens["command"]["arguments"][0]["test"], "MainTest"); + assert_eq!(lens["data"]["id"], "lens-1"); +} + +#[test] +fn navigation_locations_preserve_uris_without_fabricating_virtual_file_paths() { + let uri = "file:///tmp/project/Main.java"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "java".to_string(), + text: "class Main {}\n".to_string(), + }) + .unwrap(); + let requested = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: uri.to_string(), + method: "textDocument/definition".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 6, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: requested.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": [{ + "uri": "file:///tmp/project/Main%20File.java", + "range": { + "start": { "line": 1, "character": 2 }, + "end": { "line": 1, "character": 6 } + } + }, { + "uri": "jdt://contents/java.base/java/lang/String.class", + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 10 } + } + }] + }) + .to_string(), + }) + .unwrap(); + let locations = &response.events[0].result.as_ref().unwrap()["locations"]; + + assert_eq!(locations[0]["uri"], "file:///tmp/project/Main%20File.java"); + assert_eq!(locations[0]["filePath"], "/tmp/project/Main File.java"); + assert_eq!(locations[0]["isReadOnly"], false); + assert_eq!( + locations[1]["uri"], + "jdt://contents/java.base/java/lang/String.class" + ); + assert!(locations[1]["filePath"].is_null()); + assert_eq!(locations[1]["isReadOnly"], true); + assert!(locations[1]["displayPath"].is_null()); +} + #[test] fn client_core_applies_diagnostics_and_dynamic_registrations() { - let state = LspClientState::default(); + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.py".to_string(), + language_id: "python".to_string(), + text: "example = value\n".to_string(), + }) + .unwrap(); let diagnostics = client_apply_server_message(ClientApplyServerMessageRequest { - state, + state: opened.state, message: r#"{ "jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": { "uri": "file:///tmp/project/main.py", + "version": 1, "diagnostics": [{ "range": { "start": { "line": 2, "character": 4 }, @@ -1194,7 +1643,16 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { "Type declared here" ); assert_eq!(diagnostics.events[0].kind, "diagnostics"); + assert_eq!(diagnostics.events[0].version, Some(1)); + assert_eq!( + diagnostics + .state + .diagnostic_versions + .get("file:///tmp/project/main.py"), + Some(&1) + ); let event_json = serde_json::to_value(&diagnostics.events[0]).unwrap(); + assert_eq!(event_json["version"], 1); assert_eq!(event_json["diagnostics"][0]["tags"], json!([1, 2, 99])); assert_eq!( event_json["diagnostics"][0]["relatedInformation"][0]["location"]["range"]["start"] @@ -1213,6 +1671,22 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { "id": "formatting", "method": "textDocument/formatting", "registerOptions": {} + }, { + "id": "inlay-hints", + "method": "textDocument/inlayHint", + "registerOptions": {} + }, { + "id": "folding-ranges", + "method": "textDocument/foldingRange", + "registerOptions": {} + }, { + "id": "code-lens", + "method": "textDocument/codeLens", + "registerOptions": {} + }, { + "id": "workspace-symbols", + "method": "workspace/symbol", + "registerOptions": {} }] } }"# @@ -1223,6 +1697,17 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { .state .server_capabilities .contains(&"formatting".to_string())); + for feature in [ + "inlayHints", + "foldingRanges", + "codeLens", + "workspaceSymbols", + ] { + assert!(registered + .state + .server_capabilities + .contains(&feature.to_string())); + } let response: Value = serde_json::from_str(®istered.messages[0]).unwrap(); assert_eq!( response, @@ -1239,6 +1724,18 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { "unregisterations": [{ "id": "formatting", "method": "textDocument/formatting" + }, { + "id": "inlay-hints", + "method": "textDocument/inlayHint" + }, { + "id": "folding-ranges", + "method": "textDocument/foldingRange" + }, { + "id": "code-lens", + "method": "textDocument/codeLens" + }, { + "id": "workspace-symbols", + "method": "workspace/symbol" }] } }"# @@ -1249,6 +1746,17 @@ fn client_core_applies_diagnostics_and_dynamic_registrations() { .state .server_capabilities .contains(&"formatting".to_string())); + for feature in [ + "inlayHints", + "foldingRanges", + "codeLens", + "workspaceSymbols", + ] { + assert!(!unregistered + .state + .server_capabilities + .contains(&feature.to_string())); + } let response: Value = serde_json::from_str(&unregistered.messages[0]).unwrap(); assert_eq!( response, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index aa795ca3..5dc03a9e 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -36,6 +36,16 @@ pub enum CoreCommand { LspBuiltinCompletions, LspBuiltinHover, LspBuiltinNavigation, + LspStartServer, + LspStopServer, + LspSyncDocument, + LspCloseDocument, + LspRequest, + LspCancelOperation, + LspPollEvents, + LspClearDiagnostics, + LspSnapshot, + LspDestroyServer, LspClientInitialize, LspClientOpenDocument, LspClientChangeDocument, @@ -98,6 +108,16 @@ impl CoreCommand { "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), "lsp.builtinHover" => Some(Self::LspBuiltinHover), "lsp.builtinNavigation" => Some(Self::LspBuiltinNavigation), + "lsp.startServer" => Some(Self::LspStartServer), + "lsp.stopServer" => Some(Self::LspStopServer), + "lsp.syncDocument" => Some(Self::LspSyncDocument), + "lsp.closeDocument" => Some(Self::LspCloseDocument), + "lsp.request" => Some(Self::LspRequest), + "lsp.cancelOperation" => Some(Self::LspCancelOperation), + "lsp.pollEvents" => Some(Self::LspPollEvents), + "lsp.clearDiagnostics" => Some(Self::LspClearDiagnostics), + "lsp.snapshot" => Some(Self::LspSnapshot), + "lsp.destroyServer" => Some(Self::LspDestroyServer), "lsp.clientInitialize" => Some(Self::LspClientInitialize), "lsp.clientOpenDocument" => Some(Self::LspClientOpenDocument), "lsp.clientChangeDocument" => Some(Self::LspClientChangeDocument), @@ -160,4 +180,22 @@ mod tests { Some(CoreCommand::LspSessionExecute) )); } + + #[test] + fn parses_semantic_lsp_runtime_commands() { + for command in [ + "lsp.startServer", + "lsp.stopServer", + "lsp.syncDocument", + "lsp.closeDocument", + "lsp.request", + "lsp.cancelOperation", + "lsp.pollEvents", + "lsp.clearDiagnostics", + "lsp.snapshot", + "lsp.destroyServer", + ] { + assert!(CoreCommand::parse(command).is_some(), "missing {command}"); + } + } } diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 95756b88..124488f2 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -338,6 +338,175 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspStartServer => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP start-server request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::start_server) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP start-server response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspStopServer => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP stop-server request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::stop_server) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP stop-server response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspSyncDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP sync-document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::sync_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP sync-document response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspCloseDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP close-document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::close_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP close-document response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid semantic LSP request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::semantic_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Semantic LSP response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspCancelOperation => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP cancellation request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::cancel_operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP cancellation response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspPollEvents => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP poll-events request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::poll_events) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP poll-events response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClearDiagnostics => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP clear-diagnostics request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::clear_diagnostics) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("LSP clear-diagnostics response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspSnapshot => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP snapshot request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::snapshot) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP snapshot response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspDestroyServer => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP destroy-server request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::destroy_server) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP destroy-server response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspClientInitialize => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1015,4 +1184,92 @@ mod tests { assert_eq!(shutdown["id"], "1"); assert!(shutdown.get("params").is_none()); } + + #[test] + fn routes_semantic_lsp_runtime_commands() { + let unknown_session = "missing-runtime-session"; + let requests = [ + json!({ + "command": "lsp.stopServer", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.syncDocument", + "payload": { + "sessionId": unknown_session, + "uri": "file:///tmp/main.go", + "languageId": "go", + "text": "package main\n" + } + }), + json!({ + "command": "lsp.closeDocument", + "payload": { + "sessionId": unknown_session, + "uri": "file:///tmp/main.go" + } + }), + json!({ + "command": "lsp.request", + "payload": { + "sessionId": unknown_session, + "operationId": "completion-1", + "operation": "completion", + "uri": "file:///tmp/main.go", + "position": { "line": 0, "utf16Column": 0 } + } + }), + json!({ + "command": "lsp.cancelOperation", + "payload": { + "sessionId": unknown_session, + "operationId": "completion-1" + } + }), + json!({ + "command": "lsp.pollEvents", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.clearDiagnostics", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.snapshot", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.destroyServer", + "payload": { "sessionId": unknown_session } + }), + ]; + + for request in requests { + let response: Value = + serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], false, "request should reach the runtime"); + assert_eq!(response["error"]["code"], "invalid_request"); + assert_eq!(response["error"]["details"], unknown_session); + } + + let invalid_start: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "lsp.startServer", + "payload": { + "providerId": "go", + "executablePath": "", + "rootUri": "file:///tmp/project", + "workingDirectory": "/tmp/project" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(invalid_start["ok"], false); + assert_eq!(invalid_start["error"]["code"], "invalid_request"); + assert_eq!( + invalid_start["error"]["details"], + "executablePath/workingDirectory" + ); + } } diff --git a/scripts/verify-service-boundaries.sh b/scripts/verify-service-boundaries.sh index 42a8246b..ce54dcf0 100755 --- a/scripts/verify-service-boundaries.sh +++ b/scripts/verify-service-boundaries.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" core_pattern='import (SwiftUI|AppKit|CoreServices)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle' service_pattern='import (SwiftUI|AppKit)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle|String\(contentsOf:|Data\(contentsOf:|write\(to:.*encoding:|\bMac[A-Z][A-Za-z]+\b|/opt/homebrew|/usr/local|/usr/bin' -ui_service_pattern='\b(MavenService|JavaRunService|JavaDebugService|ProjectRuntimeService|JavaImplementationMarkerService|GitService|WorkspaceSearchIndex)\b' +ui_service_pattern='\b(MavenService|JavaRunService|JavaDebugService|ProjectRuntimeService|GitService|WorkspaceSearchIndex)\b' composition_pattern='\bMac[A-Z][A-Za-z]+\b' application_ui_pattern='import AppKit|\b(NSOpenPanel|NSWorkspace|NSPasteboard|NSEvent)\b' appmodel_business_pattern='Task\.detached|LocalHistoryService|WorkspaceTextFilePolicy|DirectoryChangeSource|fileOperations\.(fileExists|isDirectory|createFile|createDirectory|copyItem|moveItem|removeItem|trashItem|writeText)|mavenFeature\.loadProject|runFeature\.loadProject|configuration\.kind|debugFeature\.(startMaven|toggleBreakpoint|attachRemote)' diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 80dfe933..83e58bfa 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -27,8 +27,8 @@ verification scripts are the executable source of boundary checks. | Search | query matching, deterministic result ordering, symbols, and replacement preview | workspace lifecycle and optional index persistence | | Git | changes, commits, branches, diffs, history, validation, and mutation results | Git executable discovery, credentials, process environment | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | -| Language tooling | provider catalog, local fallback results, LSP state, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable discovery, stdio process transport, environment, and termination | -| Java/Maven | Maven project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, and run-configuration detection | JDK/Maven discovery, JDT LS, Java/Maven child processes, sockets, JDB/LSP transport | +| Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | +| Java/Maven | Maven project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, and JDTLS adapter policy | JDK/Maven discovery, Java/Maven child processes, sockets, and JDB transport | | Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Local History | revision metadata, text content, restore result | persistence location and file operations | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 42258a71..501868fa 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -75,15 +75,16 @@ stable error code and a user-facing message: | `lsp.builtinCompletions` | Return lightweight current-file identifier completions | | `lsp.builtinHover` | Return lightweight current-symbol hover text | | `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | -| `lsp.clientInitialize` | Create an LSP initialize JSON-RPC request and client state | -| `lsp.clientOpenDocument` | Track an open document and emit `textDocument/didOpen` | -| `lsp.clientChangeDocument` | Track a full-text document change and emit `textDocument/didChange` | -| `lsp.clientCloseDocument` | Remove an open document and emit `textDocument/didClose` | -| `lsp.clientShutdown` | Record graceful shutdown and emit the `shutdown` request | -| `lsp.clientRequest` | Emit a typed LSP feature request and record the pending request | -| `lsp.clientApplyServerMessage` | Apply LSP server responses, diagnostics, and dynamic registrations | -| `lsp.frameMessage` | Frame one JSON-RPC payload for an LSP stdio transport | -| `lsp.parseServerMessages` | Incrementally parse framed messages from an LSP stdout byte stream | +| `lsp.startServer` | Start one Rust-owned process/session and begin initialization | +| `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | +| `lsp.syncDocument` | Open or full-text change a Rust-owned document with monotonic versions | +| `lsp.closeDocument` | Close a document and clear its diagnostics | +| `lsp.request` | Submit a typed semantic request and return an opaque operation ID | +| `lsp.cancelOperation` | Cancel one pending semantic operation | +| `lsp.pollEvents` | Drain ordered typed lifecycle/feature/diagnostic/result/log events | +| `lsp.clearDiagnostics` | Clear every diagnostic owned by a session | +| `lsp.snapshot` | Return a diagnostic runtime snapshot for testing and control surfaces | +| `lsp.destroyServer` | Remove a terminal session handle from the registry | | `java.runConfigurations` | Scan Java sources for main classes and return Maven/Spring run configurations | | `java.codeVision` | Return Java declaration usage counts for editor code vision | | `java.className` | Resolve a Java source package and simple name into a runtime class name | @@ -208,32 +209,44 @@ server. The LSP provider catalog is returned by `lithe_core_lsp_provider_catalog_json`. Each provider descriptor may include `languageServerLaunch` with ordered `executableNames` and `arguments`; Swift adapters use this metadata when they -need to start a real language server after the lightweight Rust fallback is not -enough. Built-in descriptors are merged by provider ID with the optional +need to discover a real language-server executable; the selected launch plan is +then submitted to the Rust-owned runtime. Built-in descriptors are merged by provider ID with the optional `.lithe/lsp/language-providers.json` workspace document. See [`language-tooling.md`](../../docs/architecture/language-tooling.md) for routing, discovery, lifecycle, and compatibility rules. -`lsp.client*` commands are the transport-independent LSP client core. The -platform adapter owns the process/stdin/stdout transport and passes a -serialized `state` object through these commands. Responses return -`{ "state": object, "messages": string[], "events": [] }`; `messages` are raw -JSON-RPC payloads for the adapter to frame and write to the language server. -`lsp.clientInitialize` records the pending initialize request and emits -`initialize`. `lsp.clientOpenDocument`, `lsp.clientChangeDocument`, and -`lsp.clientCloseDocument` maintain document state and emit full-text lifecycle -notifications. `lsp.clientShutdown` emits `shutdown`; applying its response -clears session state and emits `exit`. `lsp.clientRequest` supports completion, -hover, definition/declaration/typeDefinition, -implementation, references, rename, formatting, code action, resolve, and -execute-command methods. `lsp.clientApplyServerMessage` parses server -responses, derives feature names from initialize capabilities, stores -`publishDiagnostics`, handles dynamic register/unregister notifications, and -answers the supported workspace/window requests. Unknown server requests -receive JSON-RPC `Method not found` instead of being silently ignored. -Completion, hover, and navigation responses are normalized by Rust into the -same completion item, hover, and location payload shapes used by the lightweight -fallback commands. +The `lsp.*Server`, `lsp.*Document`, `lsp.request`, and `lsp.pollEvents` +commands are the semantic LSP runtime boundary. `lsp.startServer` accepts the +provider ID, selected executable/arguments/environment, root URI, working +directory, initialization options, optional runtime executable and cache +directory, plus initialize/request/shutdown deadlines. Rust owns the returned +session's child process, stdin/stdout/stderr, framing buffer, JSON-RPC request +IDs, document versions, pending deadlines, capabilities, diagnostics, and +graceful/forced termination. + +`lsp.syncDocument` accepts `{ sessionId, uri, languageId, text }`; the first +sync emits `didOpen` at version 1 and later syncs emit full-text `didChange` +with increasing versions. `lsp.request` accepts a semantic `operation` plus +the operation-specific URI, position, range, diagnostics, item, action, or +command fields, and returns `{ operationId }`. Supported operations include +completion, hover, definition/declaration/type-definition, references, +implementation, rename, formatting, code actions and resolve, execute command, +inlay hints, folding ranges, code lens, and provider virtual documents. + +`lsp.pollEvents` drains events ordered by per-session `sequence`. Event types +include `stateChanged`, `featuresChanged`, `diagnostics`, +`requestCompleted`, `serverInfoChanged`, and `log`. Every request completes at +most once with either `result` or a structured runtime error containing +provider/session, stage, optional method/document/request, stable code, and +optional process-exit detail. Late responses after cancellation or deadline +are ignored. Diagnostics are accepted only for documents open in the current +session, and versioned diagnostics must match the current document version. + +The client reducer, raw JSON-RPC message, frame, and parser functions are +internal Rust implementation seams; they are not public application commands. +Completion, hover, navigation, edit, hint, folding, and code-lens responses are +normalized by Rust before they cross the application boundary. Unknown server +requests receive JSON-RPC `Method not found` instead of being silently ignored. The `history.*` commands accept an adapter-selected `storageRoot`; history metadata never stores an absolute workspace or storage path. `history.record`